Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6be0ce65 | ||
|
|
452ec77449 | ||
|
|
aa87fa917d | ||
|
|
e36125b24a | ||
|
|
fdda52ff0a | ||
|
|
a2c63b3cda | ||
|
|
b2b2cad5ac | ||
|
|
5ec4f3f7cd | ||
|
|
74bdbebd57 | ||
|
|
b31e71ac88 | ||
|
|
b05f3bebc7 | ||
|
|
ce422e6436 | ||
|
|
2ac26acf91 |
@@ -67,9 +67,12 @@ jobs:
|
||||
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
||||
|
||||
- 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: |
|
||||
STATUS_JSON=$(curl -fsS \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
||||
echo "$STATUS_JSON" | python3 -c "
|
||||
import json, sys
|
||||
|
||||
@@ -172,6 +172,7 @@ _MAIN_QUERY = text("""
|
||||
foi.ofsted_grade,
|
||||
foi.ofsted_date,
|
||||
foi.ofsted_framework,
|
||||
foi.ofsted_rc_date,
|
||||
l.local_authority_name AS local_authority,
|
||||
l.local_authority_code,
|
||||
l.address_line1 AS address1,
|
||||
@@ -256,7 +257,12 @@ _MAIN_QUERY = text("""
|
||||
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
||||
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
||||
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
|
||||
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||
) foi ON s.urn = foi.urn
|
||||
|
||||
@@ -550,6 +550,7 @@ SCHOOL_COLUMNS = [
|
||||
"ofsted_grade",
|
||||
"ofsted_date",
|
||||
"ofsted_framework",
|
||||
"ofsted_rc_date",
|
||||
"latitude",
|
||||
"longitude",
|
||||
]
|
||||
|
||||
@@ -80,6 +80,33 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
|
||||
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 }) => {
|
||||
await searchByName(page, 'primary');
|
||||
const firstSchool = schoolLinks(page).first();
|
||||
@@ -94,6 +121,33 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
||||
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('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
|
||||
|
||||
@@ -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' });
|
||||
@@ -130,9 +160,23 @@ describe('buildOfstedListBadge', () => {
|
||||
expect(badge.cssClass).toBe('ofsted2');
|
||||
});
|
||||
|
||||
it('returns Report Card badge when framework is ReportCard', () => {
|
||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
|
||||
expect(badge.label).toBe('Report Card · 2025');
|
||||
it('returns a Report Card badge when ofsted_rc_date is present', () => {
|
||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
|
||||
expect(badge.label).toBe('Report Card · 2026');
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,13 @@ interface 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 yearStr = year ? ` · ${year}` : '';
|
||||
if (school.ofsted_grade) {
|
||||
@@ -55,9 +62,6 @@ function buildPopupBadge(school: School): PopupBadge {
|
||||
};
|
||||
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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ import type {
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
|
||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas,
|
||||
} from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
@@ -85,6 +86,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);
|
||||
@@ -269,18 +274,28 @@ export function SchoolDetailView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 ───────────
|
||||
const oeifAllSameGrade = (() => {
|
||||
if (!ofsted || ofsted.framework === 'ReportCard') 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 ?? '';
|
||||
@@ -507,10 +522,10 @@ export function SchoolDetailView({
|
||||
{ofsted && (
|
||||
<section id="ofsted" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofsted.inspection_date && (
|
||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofstedInspectedDate && (
|
||||
<span className={styles.ofstedDate}>
|
||||
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
@@ -525,7 +540,7 @@ export function SchoolDetailView({
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{ofsted.framework === 'ReportCard' ? (
|
||||
{isReportCard ? (
|
||||
/* ── New Report Card layout ── */
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
@@ -568,7 +583,9 @@ 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}>
|
||||
@@ -576,15 +593,7 @@ export function SchoolDetailView({
|
||||
</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 && (
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
@@ -607,8 +616,8 @@ export function SchoolDetailView({
|
||||
</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.'}
|
||||
? '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>
|
||||
|
||||
{/* ── Primary / KS2 content ── */}
|
||||
@@ -633,7 +642,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -655,7 +664,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -761,7 +770,7 @@ export function SchoolDetailView({
|
||||
</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>
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -785,7 +794,7 @@ export function SchoolDetailView({
|
||||
</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>
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -797,7 +806,7 @@ export function SchoolDetailView({
|
||||
</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>
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -902,6 +911,9 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{admissionsSummary.chip && (
|
||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multi-year trend */}
|
||||
@@ -937,7 +949,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 && (
|
||||
@@ -953,7 +965,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>
|
||||
)}
|
||||
@@ -970,7 +982,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>
|
||||
)}
|
||||
@@ -1142,7 +1154,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>
|
||||
)}
|
||||
@@ -1154,7 +1166,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>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ 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 } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
@@ -186,18 +186,27 @@ export function SecondarySchoolDetailView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 ───────────
|
||||
const oeifAllSameGrade = (() => {
|
||||
if (!ofsted || ofsted.framework === 'ReportCard') 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;
|
||||
@@ -332,10 +341,10 @@ export function SecondarySchoolDetailView({
|
||||
{ofsted && (
|
||||
<section id="ofsted" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofsted.inspection_date && (
|
||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofstedInspectedDate && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
@@ -349,7 +358,7 @@ export function SecondarySchoolDetailView({
|
||||
Ofsted reports ↗
|
||||
</a>
|
||||
</h2>
|
||||
{ofsted.framework === 'ReportCard' ? (
|
||||
{isReportCard ? (
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
@@ -390,7 +399,9 @@ 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}>
|
||||
@@ -398,15 +409,7 @@ export function SecondarySchoolDetailView({
|
||||
</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 && (
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
@@ -449,12 +452,13 @@ 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. England averages shown for comparison.
|
||||
</p>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -478,7 +482,7 @@ export function SecondarySchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -518,7 +522,7 @@ 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>
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -540,7 +544,7 @@ 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>
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface School {
|
||||
|
||||
// Ofsted (for list view — summary only)
|
||||
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_framework?: string | null;
|
||||
}
|
||||
|
||||
+50
-6
@@ -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 {
|
||||
@@ -691,9 +727,12 @@ export interface OfstedListBadge {
|
||||
|
||||
/**
|
||||
* Build the Ofsted badge for a school card in the list/map view.
|
||||
* Three states:
|
||||
* States, in priority order:
|
||||
* - 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
|
||||
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
|
||||
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
||||
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
||||
* detail page's hero chip so a school never reads as both inspected and
|
||||
@@ -704,7 +743,16 @@ export function buildOfstedListBadge(school: {
|
||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||
ofsted_date?: string | null;
|
||||
ofsted_framework?: string | null;
|
||||
ofsted_rc_date?: string | null;
|
||||
}): 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
|
||||
? new Date(school.ofsted_date).getFullYear()
|
||||
: null;
|
||||
@@ -723,10 +771,6 @@ 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
|
||||
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
||||
// that has genuinely never been inspected.
|
||||
|
||||
Reference in New Issue
Block a user