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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The link repeated the full school name ('Watford Grammar School for Boys's
Ofsted page') — long on mobile, and the colour-coded school name already
leads the row. Show just 'Ofsted page →'; keep the full name in aria-label
so screen readers can still tell the per-school links apart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
262 lines
8.7 KiB
TypeScript
262 lines
8.7 KiB
TypeScript
/**
|
||
* Ofsted section — one visual grammar for inspection detail across all
|
||
* three regimes (legacy graded, interim carried-forward, renewed-framework
|
||
* report card). Copy comes verbatim from the reviewed mockups.
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import {
|
||
OFSTED_LEGACY_GRADES,
|
||
ofstedDisplay,
|
||
rcAreaLabel,
|
||
type OfstedDisplay,
|
||
} from '@/lib/compareLogic';
|
||
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||
|
||
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
|
||
1: 'good',
|
||
2: 'good',
|
||
3: 'warn',
|
||
4: 'bad',
|
||
};
|
||
|
||
const RC_CODE_TONE = (code: number): 'good' | 'warn' | 'bad' | 'neutral' =>
|
||
code <= 2 ? 'good' : code === 3 ? 'neutral' : code === 4 ? 'warn' : 'bad';
|
||
|
||
function formatInspectionDate(iso: string | null): string {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '—';
|
||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||
}
|
||
|
||
function yearsSince(iso: string | null): number | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||
}
|
||
|
||
function ResultCell({ display }: { display: OfstedDisplay }) {
|
||
if (display.kind === 'none') {
|
||
return <span className={s.small}>No inspection outcome in our dataset</span>;
|
||
}
|
||
if (display.kind === 'report_card') {
|
||
return (
|
||
<>
|
||
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
|
||
<span className={s.small}>New-style inspection — no overall grade is given</span>
|
||
</>
|
||
);
|
||
}
|
||
if (display.kind === 'transitional') {
|
||
return (
|
||
<>
|
||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||
No overall grade
|
||
</span>
|
||
<span className={s.small}>
|
||
Inspected under transitional framework (sub-judgements only)
|
||
</span>
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
|
||
{display.gradeLabel}
|
||
</span>
|
||
<span className={s.small}>
|
||
{display.carriedForward
|
||
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
|
||
: 'Overall grade (older-style inspection)'}
|
||
</span>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function JudgementDetailCell({
|
||
ofsted,
|
||
display,
|
||
schoolName,
|
||
}: {
|
||
ofsted: OfstedInspection;
|
||
display: OfstedDisplay;
|
||
schoolName: string;
|
||
}) {
|
||
if (display.kind === 'report_card') {
|
||
const entries = Object.entries(ofsted.report_card ?? {});
|
||
return (
|
||
<div className={s.rcList}>
|
||
{entries.map(([key, entry]) => (
|
||
<div key={key} className={s.rcRow}>
|
||
<span className={s.rcArea}>{rcAreaLabel(key)}</span>
|
||
<Chip tone={RC_CODE_TONE(entry.code)}>{entry.label}</Chip>
|
||
</div>
|
||
))}
|
||
{ofsted.rc_safeguarding_met != null && (
|
||
<div className={s.rcRow}>
|
||
<span className={s.rcArea}>Safeguarding</span>
|
||
<Chip tone={ofsted.rc_safeguarding_met ? 'good' : 'bad'}>
|
||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||
</Chip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const legacyAreas: Array<[string, number | null | undefined]> = [
|
||
['Quality of education', ofsted.quality_of_education],
|
||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||
['Personal development', ofsted.personal_development],
|
||
['Leadership & management', ofsted.leadership_management],
|
||
['Early years provision', ofsted.early_years_provision],
|
||
['Sixth form provision', ofsted.sixth_form_provision],
|
||
];
|
||
// Only real Ofsted grades (1–4) are judgements. The MI file uses sentinel
|
||
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
|
||
// must never render as a rating chip.
|
||
const published = legacyAreas.filter(
|
||
(entry): entry is [string, number] =>
|
||
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
|
||
);
|
||
|
||
if (published.length === 0) {
|
||
return (
|
||
<span className={s.small}>
|
||
We don't hold area-by-area detail for this inspection — see {schoolName}'s
|
||
Ofsted page for the full report.
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<div className={s.rcList}>
|
||
{published.map(([label, grade]) => (
|
||
<div key={label} className={s.rcRow}>
|
||
<span className={s.rcArea}>{label}</span>
|
||
<Chip tone={GRADE_TONE[grade as number] ?? 'neutral'}>
|
||
{OFSTED_LEGACY_GRADES[grade as number] ?? String(grade)}
|
||
</Chip>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CompareOfsted({
|
||
schools,
|
||
data,
|
||
}: {
|
||
schools: School[];
|
||
data: Record<string, ComparisonData>;
|
||
}) {
|
||
const displays = schools.map((school) => ofstedDisplay(data[String(school.urn)]?.ofsted));
|
||
const kinds = new Set(displays.map((d) => d.kind).filter((k) => k !== 'none'));
|
||
const mixedRegimes = kinds.size > 1;
|
||
|
||
return (
|
||
<Section
|
||
title="Ofsted inspection"
|
||
how={
|
||
<>
|
||
Ofsted is the schools inspectorate. It stopped giving a single overall grade in{' '}
|
||
<strong>September 2024</strong>; inspections between then and November 2025 kept the
|
||
area-by-area judgements without an overall grade, and from <strong>November 2025</strong>{' '}
|
||
new inspections produce a <strong>report card</strong> rating each area of school life on
|
||
a five-point scale.
|
||
{mixedRegimes && (
|
||
<> A report card and an older overall grade aren't directly comparable.</>
|
||
)}{' '}
|
||
(Ofsted's "Expected standard" rating is unrelated to the KS2 "expected
|
||
standard" test measure further down this page.)
|
||
</>
|
||
}
|
||
>
|
||
<SectionGrid schools={schools}>
|
||
<Measure label="Result">
|
||
{schools.map((school, i) => (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
<ResultCell display={displays[i]} />
|
||
</Cell>
|
||
))}
|
||
</Measure>
|
||
|
||
<Measure label="Inspected">
|
||
{schools.map((school, i) => {
|
||
const ofsted = data[String(school.urn)]?.ofsted;
|
||
// A report card is dated by its OWN inspection date. The legacy
|
||
// inspection_date belongs to an older inspection and must never
|
||
// be shown against a report card (report cards exist only from
|
||
// Nov 2025).
|
||
const dateIso =
|
||
displays[i].kind === 'report_card'
|
||
? ofsted?.rc_inspection_date ?? null
|
||
: ofsted?.inspection_date ?? null;
|
||
const age = yearsSince(dateIso);
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{formatInspectionDate(dateIso)}{' '}
|
||
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||
</Cell>
|
||
);
|
||
})}
|
||
|
||
</Measure>
|
||
|
||
<Measure
|
||
tip="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life."
|
||
label="Judgement detail"
|
||
>
|
||
{schools.map((school, i) => {
|
||
const ofsted = data[String(school.urn)]?.ofsted;
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{ofsted ? (
|
||
<JudgementDetailCell
|
||
ofsted={ofsted}
|
||
display={displays[i]}
|
||
schoolName={school.school_name}
|
||
/>
|
||
) : (
|
||
<span className={s.small}>No inspection in our dataset</span>
|
||
)}
|
||
</Cell>
|
||
);
|
||
})}
|
||
|
||
</Measure>
|
||
|
||
<Measure
|
||
tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed."
|
||
label="Ofsted page"
|
||
>
|
||
{schools.map((school, i) => {
|
||
const url =
|
||
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
|
||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{/* Short visible label to save space on mobile (the coloured
|
||
school name already leads the row); the full name stays in
|
||
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>
|
||
</Cell>
|
||
);
|
||
})}
|
||
</Measure>
|
||
</SectionGrid>
|
||
</Section>
|
||
);
|
||
}
|