feat(compare): parent-first compare screen — sections, England anchors, report cards, mobile-first #35
@@ -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}). "First choice" means
|
||||
families who ranked the school top of their application form — officially a "first
|
||||
preference". Schools never see your ranking: places are decided only by the
|
||||
school'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 school’s 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'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}>
|
||||
<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}'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 (2–4 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user