Files
school_compare/nextjs-app/components/compare/CompareOfsted.tsx
T
TudorandClaude Fable 5 66bc5523f6
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m57s
fix(compare): mobile measure-first cards to match the mockup
The grid sections (At a glance, Ofsted, Getting a place, Who goes there)
collapsed generically on mobile — grey label pills, full names wrapping
to 3 lines, no dots — making the page ~2x the mockup's height and
'significantly different' from the mobile design.

Each measure is now wrapped in a <Measure> that is display:contents on
desktop (so the label + cells still flow into the shared aligned grid,
unchanged) and a white card on mobile with compact [dot][short name]
[value] rows — matching the mobile mockup. The sticky school bar becomes
scrollable short-name pills on mobile too. Adds a shortName() util.

Desktop layout is unchanged (display:contents dissolves the wrapper).
Validated the card mechanism and real content shapes (report-card cell,
badges, %+chip rows) via static previews at both widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-15 07:50:01 +01:00

226 lines
7.4 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>
</>
);
}
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]> = [
['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],
];
const published = legacyAreas.filter(([, grade]) => grade != null);
if (published.length === 0) {
return (
<span className={s.small}>
We don&apos;t hold area-by-area detail for this inspection see {schoolName}&apos;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&apos;t directly comparable.</>
)}{' '}
(Ofsted&apos;s &quot;Expected standard&quot; rating is unrelated to the KS2 &quot;expected
standard&quot; 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;
const age = yearsSince(ofsted?.inspection_date ?? null);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(ofsted?.inspection_date ?? null)}{' '}
{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}>
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
{school.school_name}&apos;s Ofsted page
</a>
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}