PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
8.8 KiB
TypeScript
262 lines
8.8 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: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
||
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>
|
||
);
|
||
}
|