feat(compare): at-a-glance, Ofsted, admissions and community sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-13 23:49:17 +01:00
co-authored by Claude Fable 5
parent 60cbc3f46d
commit 9f2260ce50
8 changed files with 1117 additions and 2 deletions
@@ -0,0 +1,98 @@
import { render, screen } from '@testing-library/react';
import { CompareOfsted } from '@/components/compare/CompareOfsted';
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
function school(urn: number, name: string): School {
return { urn, school_name: name } as School;
}
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
return {
framework: null,
inspection_date: '2021-10-07',
inspection_type: null,
overall_effectiveness: null,
quality_of_education: null,
behaviour_attitudes: null,
personal_development: null,
leadership_management: null,
early_years_provision: null,
previous_overall: null,
rc_safeguarding_met: null,
rc_inclusion: null,
rc_curriculum_teaching: null,
rc_achievement: null,
rc_attendance_behaviour: null,
rc_personal_development: null,
rc_leadership_governance: null,
rc_early_years: null,
rc_sixth_form: null,
ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1',
...partial,
};
}
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
const data: Record<string, ComparisonData> = {
'1': {
school_info: schools[0],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
},
'2': {
school_info: schools[1],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
},
'3': {
school_info: schools[2],
yearly_data: [],
ofsted: ofsted({
inspection_date: '2025-11-14',
rc_safeguarding_met: true,
report_card: {
rc_achievement: { code: 2, label: 'Strong standard' },
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
},
}),
},
};
describe('CompareOfsted', () => {
it('renders the three regimes without inventing an overall grade for report cards', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText('Outstanding')).toBeInTheDocument();
// Carried-forward grade is shown but marked as such
expect(screen.getByText('Good')).toBeInTheDocument();
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
// Report card: label present, no overall-grade badge for that school
expect(screen.getByText('Report card')).toBeInTheDocument();
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
});
it('uses one chip-list grammar for both regimes in judgement detail', () => {
render(<CompareOfsted schools={schools} data={data} />);
// report-card area chip
expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument();
expect(screen.getByText('Needs attention')).toBeInTheDocument();
// graded school without published subgrades → honest dataset statement
expect(
screen.getAllByText(/We don't hold area-by-area detail/i).length,
).toBeGreaterThanOrEqual(1);
});
it('shows the mixed-regime comparability note only when regimes differ', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument();
});
it('links every school to its Ofsted page', () => {
render(<CompareOfsted schools={schools} data={data} />);
const links = screen.getAllByRole('link', { name: /Ofsted page/i });
expect(links).toHaveLength(3);
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
});
});
@@ -0,0 +1,124 @@
/**
* Getting a place — admissions framed the way the expert review requires:
* total applications are "named on N forms" (any preference rank, not
* head-to-head), one consistent chip metric (first-preference success),
* equal-preference and offers-vs-intake explanations up front.
*/
'use client';
import { summariseAdmissions } from '@/lib/compareLogic';
import type { ComparisonData, School } from '@/lib/types';
import { CHART_COLORS } from '@/lib/utils';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
export function CompareAdmissions({
schools,
data,
}: {
schools: School[];
data: Record<string, ComparisonData>;
}) {
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
const anyData = rows.some(Boolean);
const entryYear = rows.find(Boolean)?.year;
const entryLabel = entryYear
? `September ${String(entryYear).slice(0, 4)} entry`
: 'the most recent admissions round';
if (!anyData) {
return (
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
<></>
</Section>
);
}
return (
<Section
title="Getting a place"
how={
<>
From the most recent admissions round ({entryLabel}). &quot;First choice&quot; means
families who ranked the school top of their application form officially a &quot;first
preference&quot;. Schools never see your ranking: places are decided only by the
school&apos;s admission criteria, so listing a school lower down never hurts your chances.
These are National Offer Day offers waiting lists and appeals can change the final
intake.
</>
}
>
<SectionGrid schools={schools}>
<RowLabel tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place.">
Interest in the school
</RowLabel>
{schools.map((school, i) => {
const a = rows[i];
return (
<Cell key={school.urn} school={school} index={i}>
{a?.total_applications != null && a?.places_offered != null ? (
<>
Named on <strong>{a.total_applications.toLocaleString('en-GB')}</strong> forms ·{' '}
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel>First-choice families offered a place</RowLabel>
{schools.map((school, i) => {
const summary = summariseAdmissions(rows[i]);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.firstPrefPct != null ? (
<>
<strong>{summary.firstPrefPct}%</strong>{' '}
{summary.chip && summary.chip.tone === 'warn' && (
<Chip tone="warn">{summary.chip.text}</Chip>
)}
<span className={s.barMini}>
<i
style={{
width: `${summary.firstPrefPct}%`,
background: CHART_COLORS[i % CHART_COLORS.length],
}}
/>
</span>
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel>What this means</RowLabel>
{schools.map((school, i) => {
const a = rows[i];
const summary = summariseAdmissions(a);
let text: string | null = null;
if (summary.firstPrefPct != null) {
if (summary.firstPrefPct >= 100) {
text = `Every family who put ${school.school_name} first got a place.`;
} else if (summary.firstPrefPct >= 90) {
text = `Nearly every family who put ${school.school_name} first got a place.`;
} else if (a?.oversubscribed) {
text =
'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
} else {
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
}
}
return (
<Cell key={school.urn} school={school} index={i}>
{text ? <span className={s.small}>{text}</span> : <span className={s.small}></span>}
</Cell>
);
})}
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,180 @@
/**
* At a glance — the short version of every section below it. Copy verbatim
* from the reviewed mockups. Report-card cells summarise by counting graded
* areas (best first) and always NAME problem areas; safeguarding is a
* separate line, never a count.
*/
'use client';
import {
latestValues,
ofstedDisplay,
summariseAdmissions,
verdict,
type ReportCardSummary,
} from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
function ReportCardChips({ summary }: { summary: ReportCardSummary }) {
return (
<>
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
<span className={s.chipStack}>
{summary.counts.map((c) => (
<Chip key={c.label} tone={c.label === 'Expected standard' ? 'neutral' : 'good'}>
{c.count} area{c.count === 1 ? '' : 's'} {c.label}
</Chip>
))}
{summary.problems.map((p) => (
<Chip key={p.areaLabel} tone={p.label === 'Urgent improvement' ? 'bad' : 'warn'}>
{p.areaLabel}: {p.label}
</Chip>
))}
</span>
<span className={s.small}>
{summary.allClear && 'No areas need attention · '}
{summary.safeguarding === 'met' && 'Safeguarding met'}
{summary.safeguarding === 'not_met' && 'Safeguarding not met'}
</span>
</>
);
}
export function CompareAtAGlance({
schools,
data,
nationalAverages,
benchmarks,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
}) {
const urns = schools.map((school) => school.urn);
const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
const headlineValues = latestValues(data, urns, headlineKey);
const anchor = isSecondary
? nationalAverages?.secondary?.attainment_8_score
: nationalAverages?.primary?.rwm_expected_pct;
const medianPupils = isSecondary
? benchmarks?.secondary?.median_pupils
: benchmarks?.primary?.median_pupils;
return (
<Section title="At a glance" how="The short version — each row below is explained in its own section further down.">
<SectionGrid schools={schools}>
<RowLabel>Latest Ofsted inspection</RowLabel>
{schools.map((school, i) => {
const display = ofstedDisplay(data[String(school.urn)]?.ofsted);
return (
<Cell key={school.urn} school={school} index={i}>
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
<>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell>
);
})}
<RowLabel
tip={
isSecondary
? 'Average Attainment 8 score across GCSE subjects (latest year).'
: '% of Year 6 pupils reaching the expected standard in reading, writing and maths (latest year).'
}
>
{isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
</RowLabel>
{schools.map((school, i) => {
const value = headlineValues[i];
return (
<Cell key={school.urn} school={school} index={i}>
{value != null ? (
<>
<span className={s.big}>{isSecondary ? value.toFixed(1) : `${Math.round(value)}%`}</span>{' '}
{anchor != null && (
<Chip
tone={
verdict(value, anchor) === 'above'
? 'good'
: verdict(value, anchor) === 'below'
? 'warn'
: 'neutral'
}
>
{verdict(value, anchor) === 'above' && 'Above England average'}
{verdict(value, anchor) === 'close' && 'Close to England average'}
{verdict(value, anchor) === 'below' && 'Below England average'}
</Chip>
)}
{anchor != null && (
<span className={s.small}>
England average {isSecondary ? anchor.toFixed(1) : `${Math.round(anchor)}%`}
</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel>Getting a place</RowLabel>
{schools.map((school, i) => {
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.chip ? (
<>
<Chip tone={summary.chip.tone}>{summary.chip.text}</Chip>
{summary.interest && <span className={s.small}>{summary.interest}</span>}
</>
) : (
<span className={s.small}>No admissions data</span>
)}
</Cell>
);
})}
<RowLabel>Size</RowLabel>
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
let sizeNote: string | null = null;
if (pupils != null && medianPupils != null) {
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
else if (pupils <= medianPupils * 0.9) sizeNote = 'Smaller than average';
else sizeNote = 'About average size';
}
return (
<Cell key={school.urn} school={school} index={i}>
{pupils != null ? (
<>
{pupils.toLocaleString('en-GB')} pupils
{sizeNote && <span className={s.small}>{sizeNote}</span>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,183 @@
/**
* Who goes there — the school's community from the latest census plus GIAS
* facts. Benchmark chips use the computed state-school averages and must
* carry their provenance wording (never "England average" for computed
* figures). Copy verbatim from the reviewed mockups.
*/
'use client';
import { verdict } from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, School } from '@/lib/types';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null {
if (part == null || total == null || total === 0) return null;
return `${Math.round((part / total) * 100)}%`;
}
export function CompareCommunity({
schools,
data,
benchmarks,
}: {
schools: School[];
data: Record<string, ComparisonData>;
benchmarks?: Benchmarks;
}) {
const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
const fsmChip = (value: number | null) => {
if (value == null || bench?.disadvantaged_pct == null) return null;
const v = verdict(value, bench.disadvantaged_pct, 3);
return (
<Chip tone="neutral">
{v === 'above' && 'Above the state-school average'}
{v === 'close' && 'About the state-school average'}
{v === 'below' && 'Below the state-school average'}
</Chip>
);
};
return (
<Section
title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
>
<SectionGrid schools={schools}>
<RowLabel>Pupils on roll</RowLabel>
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined;
const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? info?.gias_total_pupils ?? null;
const capacity = info?.capacity ?? null;
let capNote: string | null = null;
if (pupils != null && capacity != null && capacity > 0) {
capNote =
pupils >= capacity
? `${capacity.toLocaleString('en-GB')} places — at or above capacity`
: `of ${capacity.toLocaleString('en-GB')} places (${Math.round((pupils / capacity) * 100)}% full)`;
}
return (
<Cell key={school.urn} school={school} index={i}>
{pupils != null ? (
<>
{pupils.toLocaleString('en-GB')}
{capNote && <span className={s.small}>{capNote}</span>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel>Girls / boys</RowLabel>
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
const girls = pctSplit(census?.female_pupils, census?.total_pupils);
const boys = pctSplit(census?.male_pupils, census?.total_pupils);
return (
<Cell key={school.urn} school={school} index={i}>
{girls && boys ? `${girls} / ${boys}` : <span className={s.small}>No data</span>}
</Cell>
);
})}
<RowLabel tip="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. Benchmark computed across state schools in our dataset.">
Free school meals
</RowLabel>
{schools.map((school, i) => {
const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null;
return (
<Cell key={school.urn} school={school} index={i}>
{fsm != null ? (
<>
{Math.round(fsm)}% {fsmChip(fsm)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset.">
English as an additional language
</RowLabel>
{schools.map((school, i) => {
const eal = data[String(school.urn)]?.census?.eal_pct ?? null;
return (
<Cell key={school.urn} school={school} index={i}>
{eal != null ? `${Math.round(eal)}%` : <span className={s.small}>No data</span>}
</Cell>
);
})}
<RowLabel tip="% of pupils receiving SEN support (not including EHC plans). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign. State-school average computed from our dataset.">
Extra learning support (SEN)
</RowLabel>
{schools.map((school, i) => {
const rows = data[String(school.urn)]?.yearly_data ?? [];
let sen: number | null = null;
for (let r = rows.length - 1; r >= 0; r--) {
if (rows[r].sen_support_pct != null) {
sen = rows[r].sen_support_pct;
break;
}
}
const high =
sen != null && bench?.sen_support_pct != null && sen >= bench.sen_support_pct * 1.75;
return (
<Cell key={school.urn} school={school} index={i}>
{sen != null ? (
<>
{Math.round(sen)}% {high && <Chip tone="neutral">Well above average</Chip>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
<RowLabel>Faith character</RowLabel>
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
const faith = info?.religious_denomination;
const none = !faith || faith === 'Does not apply' || faith === 'None';
return (
<Cell key={school.urn} school={school} index={i}>
{none ? 'None' : faith}
</Cell>
);
})}
<RowLabel>Ages</RowLabel>
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
return (
<Cell key={school.urn} school={school} index={i}>
{info?.age_range || <span className={s.small}>No data</span>}
</Cell>
);
})}
<RowLabel>Run by</RowLabel>
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
const trust = info?.trust_name;
const la = info?.local_authority ?? school.local_authority;
return (
<Cell key={school.urn} school={school} index={i}>
{trust ? trust : la ? `${la} council` : <span className={s.small}>No data</span>}
</Cell>
);
})}
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,217 @@
/**
* 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, RowLabel, 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}>
<RowLabel>Result</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
<ResultCell display={displays[i]} />
</Cell>
))}
<RowLabel>Inspected</RowLabel>
{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>
);
})}
<RowLabel 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.">
Judgement detail
</RowLabel>
{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>
);
})}
<RowLabel tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed.">
Ofsted page
</RowLabel>
{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>
);
})}
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,216 @@
/* Shared layout for the compare screen's measure-first sections.
Mobile base: each row-label becomes a measure header and each school cell
stacks under it (colour-coded via the cell's ::before school tag).
Desktop (≥761px): the mockups' grid — 200px row-label column + one column
per school (24 columns supported via --school-count). */
.section {
margin-top: 3rem;
}
.sectionTitle {
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
font-size: 1.45rem;
font-weight: 700;
margin: 0;
padding-left: 0.75rem;
border-left: 3px solid var(--accent-coral-dark);
}
.how {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0.35rem 0 0 0.95rem;
max-width: 70ch;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 0;
margin-top: 1.25rem;
}
.rowLabel {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.35rem;
background: var(--bg-secondary);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-top: 0.8rem;
}
.cell {
padding: 0.4rem 0.6rem;
font-size: 0.95rem;
}
.cell::before {
content: attr(data-school);
display: block;
font-size: 0.72rem;
font-weight: 600;
color: var(--sc, var(--text-muted));
}
.big {
font-size: 1.35rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.small {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.chip {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
padding: 0.15rem 0.6rem;
white-space: nowrap;
}
.chipGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
}
.chipWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
}
.chipBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
}
.chipNeutral {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.help {
display: inline-flex;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid var(--text-muted);
color: var(--text-muted);
font-size: 0.65rem;
align-items: center;
justify-content: center;
cursor: help;
flex: none;
}
.badge {
display: inline-block;
font-weight: 700;
border-radius: 6px;
padding: 0.25rem 0.7rem;
font-size: 0.9rem;
}
.badgeGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
}
.badgeWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
}
.badgeBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
}
.rcList {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.2rem;
}
.rcRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.rcArea {
color: var(--text-secondary);
}
.chipStack {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.3rem;
}
.barMini {
display: block;
height: 8px;
border-radius: 4px;
background: var(--bg-secondary);
overflow: hidden;
margin-top: 0.3rem;
max-width: 140px;
}
.barMini > i {
display: block;
height: 100%;
border-radius: 4px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 16px;
box-shadow: var(--shadow-soft);
padding: 1.25rem 1.5rem;
margin-top: 1rem;
}
.link {
color: var(--accent-coral-dark);
}
@media (min-width: 761px) {
.grid {
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
gap: 0 0.75rem;
}
.rowLabel {
background: none;
border-radius: 0;
margin-top: 0;
padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border-light);
}
.cell {
padding: 0.85rem 0.25rem;
border-bottom: 1px solid var(--border-light);
}
.cell::before {
content: none;
}
}
@@ -0,0 +1,97 @@
/**
* Small shared pieces for the compare sections: the section shell, the
* row-label + per-school-cell grid, and tone-mapped chips. Copy passed into
* these comes verbatim from the reviewed mockups
* (docs/superpowers/specs/mockups/) — do not paraphrase it here.
*/
'use client';
import type { CSSProperties, ReactNode } from 'react';
import type { School } from '@/lib/types';
import { CHART_TEXT_COLORS } from '@/lib/utils';
import styles from './compareSections.module.css';
export function Section({
title,
how,
children,
}: {
title: string;
how?: ReactNode;
children: ReactNode;
}) {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{title}</h2>
{how && <p className={styles.how}>{how}</p>}
{children}
</section>
);
}
export function SectionGrid({
schools,
children,
}: {
schools: School[];
children: ReactNode;
}) {
return (
<div
className={styles.grid}
style={{ '--school-count': schools.length } as CSSProperties}
>
{children}
</div>
);
}
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
return (
<div className={styles.rowLabel}>
{children}
{tip && (
<span className={styles.help} title={tip} aria-label={tip}>
?
</span>
)}
</div>
);
}
export function Cell({
school,
index,
children,
}: {
school: School;
index: number;
children: ReactNode;
}) {
return (
<div
className={styles.cell}
data-school={school.school_name}
style={{ '--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] } as CSSProperties}
>
{children}
</div>
);
}
export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral';
const CHIP_TONE_CLASS: Record<ChipTone, string> = {
good: styles.chipGood,
warn: styles.chipWarn,
bad: styles.chipBad,
neutral: styles.chipNeutral,
};
export function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }) {
return <span className={`${styles.chip} ${CHIP_TONE_CLASS[tone]}`}>{children}</span>;
}
export const sectionStyles = styles;
+2 -2
View File
@@ -229,14 +229,14 @@ export function stripPositions(
/** Latest non-null yearly value of `metricKey` per school, in `urns` order. */
export function latestValues(
data: Record<string, { yearly_data: Array<Record<string, unknown> & { year: number }> }>,
data: Record<string, { yearly_data: Array<{ year: number }> }>,
urns: number[],
metricKey: string,
): Array<number | null> {
return urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const v = rows[i][metricKey];
const v = (rows[i] as Record<string, unknown>)[metricKey];
if (typeof v === 'number' && !Number.isNaN(v)) return v;
}
return null;