Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 4e6be0ce65 fix(detail): banded first-choice interpretation + plain-English P8 absence copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s
Batch C (points 7-10):
- Point 7: the admissions section shows the first-choice offer rate but no
  sense of how severe a low rate is. Reuse the compare screen's banded
  summariseAdmissions ('More than half of first choices missed out' etc.)
  as an interpretive line under the tiles. (Selective/faith framing was
  already present via admissionsTag.)
- Point 8: replace the jargon P8-suspended banner ('not used for
  accountability purposes ... treat with caution') with the compare
  screen's plain-English 'no KS2 baseline (COVID)' explanation.
- Points 9 (disadvantaged cohort sizes) and 10 (FSM benchmark) don't apply
  to the detail page: it shows disadvantaged composition, not disadvantaged
  attainment, and no FSM-vs-benchmark comparison — so there's no small-cohort
  caveat or cross-definition fallback to fix here.
- Also fixed a stray lowercase 'national avg' provenance string Batch B
  missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 12:03:23 +01:00
tudor 452ec77449 Merge pull request 'fix(detail): 'England average' provenance labelling (Batch B)' (#65) from fix/detail-provenance-anchoring into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 48s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 43s
Reviewed-on: #65
2026-07-20 07:52:55 +00:00
tudor aa87fa917d Merge pull request 'fix(detail): Ofsted sentinel codes, sixth form, carried-forward labels (Batch A)' (#64) from fix/detail-ofsted-correctness into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 42s
Reviewed-on: #64
2026-07-20 06:21:06 +00:00
TudorandClaude Fable 5 e36125b24a fix(detail): label official DfE figures as 'England average' (provenance)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m0s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Batch B (points 5-6): the detail page called every national figure a
'National avg'. All of them come from the official-DfE national-averages
marts (KS2/KS4 headlines), so they are England averages — relabel to
match the compare screen's provenance convention ('England average' for
official figures; the detail page has no computed benchmarks, so no
'state-school average (computed)' label is needed). Point 6 (anchoring
every number) is already satisfied on the detail page via DeltaChip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 07:19:22 +01:00
TudorandClaude Fable 5 fdda52ff0a fix(detail): filter Ofsted sentinel codes, show sixth-form judgement, label carried-forward grades
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m23s
Batch A of applying compare-screen learnings to the detail page (points 2-3):

- Point 2: the legacy OEIF sub-judgement grid rendered any non-null value
  via OFSTED_LABELS[value], so Ofsted's sentinel code 9 ('not applicable')
  leaked as an undefined label, and the sixth-form judgement was dropped
  entirely. New shared ofstedLegacyAreas() helper returns only real grades
  (1-4) and includes Sixth Form provision. Both detail views + the
  all-same-grade check use it.
- Point 3: the OEIF disclaimer always claimed 'Ofsted no longer makes an
  overall judgement' even when showing a carried-forward grade. It's now
  grade_source-aware and labels carried-forward grades as such, matching
  the compare screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 07:15:33 +01:00
tudor a2c63b3cda Merge pull request 'fix(list/map): report-card schools badged as Report Card, not their old grade' (#63) from fix/list-map-report-card-signal into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #63
2026-07-18 20:28:19 +00:00
TudorandClaude Fable 5 b2b2cad5ac test/docs: harden report-card list e2e + correct badge docstring
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m4s
Review fixes on the list/map report-card PR:
- e2e precondition now hard-asserts ofsted_rc_date instead of test.skip, so
  the backend dropping the field fails loudly (that's the regression under
  test), not silently skips.
- Use page_size=5 (the real backend param); per_page was ignored and fell
  back to the default page size.
- Update buildOfstedListBadge docstring to describe the ofsted_rc_date-based,
  report-card-wins-first detection instead of the removed framework check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 21:24:09 +01:00
TudorandClaude Fable 5 5ec4f3f7cd fix(list/map): badge report-card schools as Report Card, not their carried-forward grade
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m48s
The search-result cards and map pins keyed report-card detection off
ofsted_framework === 'ReportCard', but the API sets ofsted_framework to the
raw event grouping ('Schools - S5'); worse, ofsted_grade (the carried-forward
legacy grade) was checked first and won. So report-card schools were badged
by their old grade — Barclay's pin/card read 'Outstanding · 2021' instead of
'Report Card · 2026'. Same root cause as the detail-page fix, different
surface.

Expose ofsted_rc_date on the list serialization (the report-card inspection
date, non-null only for report cards) and make both badge builders treat a
present rc-date as winning over any grade, using its year. Removes the dead
framework === 'ReportCard' branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 21:14:57 +01:00
tudor 74bdbebd57 Merge pull request 'fix(detail): render & correctly date Ofsted report cards' (#62) from fix/detail-report-card-render-date into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 19s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 40s
Reviewed-on: #62
2026-07-18 18:13:25 +00:00
tudor b05f3bebc7 Merge pull request 'fix(ci): promotion E2E-gate check uses Actions token, not REGISTRY_TOKEN (403)' (#61) from fix/promote-status-token into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #61
2026-07-18 15:25:28 +00:00
TudorandClaude Fable 5 ce422e6436 fix(ci): use the Actions token, not REGISTRY_TOKEN, to read the E2E commit status
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 36s
Promotion's 'Verify the staging E2E gate' step called the Gitea
commit-status API with REGISTRY_TOKEN, which has container-registry scope
but no repository scope — so the API returned 403 and promotion failed.
Switch to the built-in GITHUB_TOKEN (repo read scope), matching how
pr-checks.yml already authenticates to the Gitea API. REGISTRY_TOKEN is
still used for the docker registry login, which is its correct scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 16:20:03 +01:00
10 changed files with 194 additions and 75 deletions
+4 -1
View File
@@ -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
+7 -1
View File
@@ -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
+1
View File
@@ -550,6 +550,7 @@ SCHOOL_COLUMNS = [
"ofsted_grade",
"ofsted_date",
"ofsted_framework",
"ofsted_rc_date",
"latitude",
"longitude",
]
+27
View File
@@ -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();
+47 -3
View File
@@ -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');
});
+7 -3
View File
@@ -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' };
}
+31 -34
View File
@@ -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);
@@ -285,17 +290,12 @@ export function SchoolDetailView({
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => {
if (!ofsted || isReportCard) return false;
const subs = [
ofsted.quality_of_education,
ofsted.behaviour_attitudes,
ofsted.personal_development,
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
@@ -583,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}>
@@ -591,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}`]}`}>
@@ -622,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 ── */}
@@ -648,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>
)}
@@ -670,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>
)}
@@ -776,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>
)}
@@ -800,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>
)}
@@ -812,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>
)}
@@ -917,6 +911,9 @@ export function SchoolDetailView({
</div>
)}
</dl>
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
</div>
{/* Multi-year trend */}
@@ -952,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 && (
@@ -968,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>
)}
@@ -985,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>
)}
@@ -1157,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>
)}
@@ -1169,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';
@@ -201,17 +201,12 @@ export function SecondarySchoolDetailView({
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => {
if (!ofsted || isReportCard) return false;
const subs = [
ofsted.quality_of_education,
ofsted.behaviour_attitudes,
ofsted.personal_development,
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
@@ -404,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}>
@@ -412,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}`]}`}>
@@ -463,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&apos;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>
)}
@@ -492,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>
)}
@@ -532,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>
)}
@@ -554,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>
)}
+3
View File
@@ -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
View File
@@ -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 (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 ──────────────────────────────────────────────────
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.