Merge branch 'main' into fix/gias-blank-name-codes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 33s

This commit is contained in:
2026-07-16 09:14:42 +00:00
74 changed files with 8558 additions and 1071 deletions
@@ -65,3 +65,9 @@
min-width: 0;
}
}
.chartNote {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
+40 -16
View File
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import { buildCompareChart } from '@/lib/compareChartData';
import type { ComparisonData } from '@/lib/types';
import {
CHART_COLORS,
@@ -34,13 +35,16 @@ interface ComparisonChartProps {
schools: Array<{ urn: number; school_name: string }>;
metric: string;
metricLabel: string;
/** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>;
}
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
return <div>No data available</div>;
}
// Union of years across all schools — coverage differs between them.
const years = [
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
// Pure, tested series construction: union of years with cancelled /
// unpublished years kept as real gaps, plus the England overlay.
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
const { years } = built;
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const data = comparisonData[String(school.urn)];
const color = CHART_COLORS[index % CHART_COLORS.length];
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
const school = schools[series.schoolIndex];
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
label: school.school_name,
data: years.map((year) => {
const yearData = data?.yearly_data.find((d) => d.year === year);
if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null;
}),
label: series.label,
data: series.data,
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3,
spanGaps: true,
// Never bridge missing years — gaps are information (COVID
// cancellations, unpublished 2021/22, schools that opened later).
spanGaps: false,
};
});
if (built.englandDataset) {
datasets.push({
label: built.englandDataset.label,
data: built.englandDataset.data,
borderColor: 'rgba(109, 104, 95, 0.9)',
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: built.englandDataset.borderDash,
pointStyle: 'line',
pointRadius: 0,
pointHoverRadius: 4,
tension: 0,
spanGaps: false,
});
}
const chartData = {
labels: years.map(formatAcademicYear),
datasets,
@@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
{built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
</p>
)}
</div>
);
}
+105 -370
View File
@@ -28,8 +28,15 @@
color: var(--text-secondary, #5c564d);
margin: 0;
line-height: 1.6;
max-width: 60ch;
}
.headerActions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Phase Tabs */
.phaseTabs {
@@ -72,408 +79,136 @@
background: var(--accent-coral-darker, #9c3f26);
}
/* Metric Selector */
.metricSelector {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
/* Sticky school bar — column identity while scrolling; horizontal scroll on
narrow screens. Offset by the sticky site header's height (Navigation is
position: sticky, top: 0) so this bar pins just below it instead of
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
.schoolBar {
position: sticky;
top: 65px;
z-index: 10;
background: var(--bg-primary, #faf7f2);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.metricLabel {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
white-space: nowrap;
}
.metricSelect {
flex: 1;
max-width: 400px;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
background: var(--bg-card, white);
color: var(--text-primary, #1a1612);
cursor: pointer;
transition: all 0.2s ease;
}
.metricSelect:hover {
border-color: var(--accent-coral, #e07256);
}
.metricSelect:focus {
outline: none;
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px var(--accent-coral-bg);
}
.metricSelect optgroup {
font-weight: 700;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
padding: 0.5rem 0;
}
.metricSelect option {
font-weight: 400;
color: var(--text-secondary, #5c564d);
padding: 0.375rem 1rem;
}
/* Schools Section */
.schoolsSection {
margin-bottom: 2rem;
}
.schoolsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.schoolCard {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-left: 3px solid var(--accent-teal, #2d7d7d);
border-radius: 12px;
padding: 1.5rem;
position: relative;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.schoolCard:hover {
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
transform: translateY(-2px);
}
.removeButton {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-coral, #e07256);
color: white;
border: none;
border-radius: 50%;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.removeButton:hover {
background: var(--accent-coral-dark, #c45a3f);
transform: scale(1.1);
}
.schoolName {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.75rem;
padding-right: 2rem;
line-height: 1.3;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.schoolName a {
color: var(--text-primary, #1a1612);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolName a:hover {
color: var(--accent-coral-dark, #b04a2e);
}
.schoolMeta {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
flex: 1;
}
.metaItem {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
display: flex;
align-items: center;
gap: 0.25rem;
}
.latestValue {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e5dfd5);
text-align: center;
background: var(--bg-secondary, #f3ede4);
margin-left: -1.5rem;
margin-right: -1.5rem;
margin-bottom: -1.5rem;
padding: 1.25rem 1.5rem;
border-radius: 0 0 12px 9px;
}
.latestLabel {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.latestNumber {
font-size: 1.75rem;
font-weight: 700;
color: var(--accent-teal, #2d7d7d);
}
/* Chart Section */
.chartSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.sectionTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-color, #e5dfd5);
font-family: var(--font-playfair), 'Playfair Display', serif;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sectionTitle::before {
content: '';
display: inline-block;
width: 4px;
height: 1em;
background: var(--accent-coral, #e07256);
border-radius: 2px;
}
.chartContainer {
width: 100%;
height: 400px;
position: relative;
}
.loadingMessage {
text-align: center;
padding: 3rem;
color: var(--text-secondary, #5c564d);
font-size: 1rem;
}
/* Table Section */
.tableSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.tableWrapper {
gap: 0.75rem;
overflow-x: auto;
max-width: 100%;
margin-top: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light, #e5dfd5);
-webkit-overflow-scrolling: touch;
}
/* Right-edge fade so phone users see the comparison table scrolls.
Otherwise the wider-than-viewport table silently clips. */
@media (max-width: 640px) {
.tableWrapper {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.schoolChip {
flex: 1 1 0;
min-width: 180px;
background: var(--bg-card, white);
border: 1px solid var(--border-light, #e5dfd5);
border-top: 3px solid var(--accent-coral, #e07256);
border-radius: 8px;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
padding: 0.55rem 0.75rem;
display: flex;
gap: 0.55rem;
align-items: center;
}
.comparisonTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.9375rem;
.chipDot {
width: 11px;
height: 11px;
border-radius: 50%;
flex: none;
}
.comparisonTable thead {
background: var(--bg-secondary, #f3ede4);
.chipText {
min-width: 0;
}
.comparisonTable th {
padding: 1rem;
text-align: left;
.chipName {
display: block;
font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5);
background: var(--bg-secondary, #f3ede4);
text-decoration: none;
}
.chipName:hover {
color: var(--accent-coral-dark, #b04a2e);
}
/* Full name on desktop, short name on the compact mobile pills. */
.chipNameShort {
display: none;
}
.chipMeta {
display: block;
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
white-space: nowrap;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
overflow: hidden;
text-overflow: ellipsis;
}
.comparisonTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
color: var(--text-secondary, #5c564d);
text-align: left;
background: var(--bg-card, white);
}
/* Sticky first column (Year) so labels remain visible while scrolling */
.comparisonTable th:first-child,
.comparisonTable td:first-child {
position: sticky;
left: 0;
z-index: 1;
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
}
.comparisonTable thead th:first-child {
z-index: 2;
}
.comparisonTable tbody tr:hover td:first-child {
.chipRemove {
margin-left: auto;
border: none;
background: var(--bg-secondary, #f3ede4);
color: var(--text-muted, #6d685f);
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
flex: none;
font-size: 0.9rem;
line-height: 1;
}
.comparisonTable tbody tr:last-child td {
border-bottom: none;
.footnote {
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
margin-top: 2.5rem;
border-top: 1px solid var(--border-light, #e5dfd5);
padding-top: 1rem;
max-width: 75ch;
}
.comparisonTable tbody tr:hover {
background: var(--bg-secondary, #f3ede4);
}
.yearCell {
font-weight: 700;
color: var(--accent-gold, #c9a227);
}
/* Empty State */
.emptyState {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.emptyStateDescription {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
max-width: 400px;
margin: 0 auto 1.5rem;
}
.metricDescription {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
max-width: 600px;
flex-basis: 100%;
margin-top: 0.25rem;
}
.progressNote {
background: var(--bg-secondary);
border-left: 3px solid var(--accent-teal);
padding: 0.75rem 1rem;
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
align-items: stretch;
/* Mobile: the sticky school bar becomes compact, horizontally-scrollable
pills with short names (matching the mobile mockup) instead of full-width
cards whose names wrap to several lines. */
@media (max-width: 640px) {
/* The mobile Navigation header is shorter (≈57px). */
.schoolBar {
top: 57px;
}
.header h1 {
font-size: 1.75rem;
.schoolChip {
flex: 0 0 auto;
min-width: 0;
border-top-width: 2px;
border-radius: 999px;
padding: 0.35rem 0.7rem;
box-shadow: none;
}
.metricSelector {
flex-direction: column;
align-items: stretch;
padding: 1rem;
border-radius: 8px;
.chipName {
font-size: 0.85rem;
white-space: nowrap;
}
.metricSelect {
max-width: 100%;
.chipNameFull {
display: none;
}
.schoolsGrid {
grid-template-columns: 1fr;
.chipNameShort {
display: inline;
}
.chartSection,
.tableSection {
padding: 1rem;
border-radius: 8px;
.chipMeta {
display: none;
}
.chartContainer {
/* Taller than desktop's proportion would suggest: the chip legend row
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
}
.comparisonTable {
font-size: 0.875rem;
}
.comparisonTable th,
.comparisonTable td {
padding: 0.75rem 0.5rem;
}
.latestValue {
margin-left: -1rem;
margin-right: -1rem;
margin-bottom: -1rem;
padding: 1rem;
border-radius: 0 0 8px 5px;
.chipRemove {
width: 18px;
height: 18px;
font-size: 0.75rem;
}
}
+217 -295
View File
@@ -1,49 +1,42 @@
/**
* ComparisonView Component
* Client-side comparison interface with phase tabs, charts, and tables
* ComparisonView — the parent-first compare screen: a sticky school bar and
* six sections (At a glance / Ofsted / Academics / Getting a place / Who
* goes there / Explore trends), every number anchored against the England
* average or the computed state-school benchmark with provenance-correct
* labels. Layout and copy follow the reviewed mockups
* (docs/superpowers/specs/mockups/).
*/
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison';
const ComparisonChart = dynamic(
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
import { CompareAtAGlance } from './compare/CompareAtAGlance';
import { CompareOfsted } from './compare/CompareOfsted';
import { CompareAcademics } from './compare/CompareAcademics';
import { CompareAdmissions } from './compare/CompareAdmissions';
import { CompareCommunity } from './compare/CompareCommunity';
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
import type {
Benchmarks,
ComparisonData,
MetricDefinition,
NationalAverages,
School,
} from '@/lib/types';
import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils';
import { fetchComparison } from '@/lib/api';
import { track } from '@/lib/analytics';
import styles from './ComparisonView.module.css';
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
const SECONDARY_CATEGORIES = ['gcse'];
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null;
initialNationalAverages?: NationalAverages;
initialBenchmarks?: Benchmarks;
initialUrns: number[];
metrics: MetricDefinition[];
selectedMetric: string;
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
export function ComparisonView({
initialData,
initialNationalAverages,
initialBenchmarks,
initialUrns,
metrics,
selectedMetric: initialMetric,
@@ -58,11 +53,15 @@ export function ComparisonView({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData);
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>(
initialNationalAverages,
);
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(initialBenchmarks);
const [shareConfirm, setShareConfirm] = useState(false);
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Tracks whether the user has explicitly clicked a phase tab.
@@ -77,24 +76,27 @@ export function ComparisonView({
if (!isInitialized) return;
if (initialUrns.length > 0 && initialData) {
const urlSchools = initialUrns
.map(urn => initialData[String(urn)]?.school_info)
.map((urn) => initialData[String(urn)]?.school_info)
.filter((info): info is NonNullable<typeof info> => Boolean(info));
const sameSet =
urlSchools.length === selectedSchools.length &&
urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn));
urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
if (urlSchools.length > 0 && !sameSet) {
replaceSchools(urlSchools);
}
}
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync URL with selected schools
const urnKey = selectedSchools.map((s) => s.urn).join(',');
// Sync the URL with the selection + metric. Pure navigation state — no
// fetching here: metric changes are presentational (the data is already
// client-side) and must not refire the comparison request.
useEffect(() => {
const urns = selectedSchools.map((s) => s.urn).join(',');
const params = new URLSearchParams(searchParams);
if (urns) {
params.set('urns', urns);
if (urnKey) {
params.set('urns', urnKey);
} else {
params.delete('urns');
}
@@ -103,52 +105,70 @@ export function ComparisonView({
const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false });
}, [urnKey, selectedMetric, pathname, searchParams, router]);
// Fetch comparison data
if (selectedSchools.length > 0) {
fetchComparison(urns, { cache: 'no-store' })
.then((data) => {
setComparisonData(data.comparison);
})
.catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
});
} else {
setComparisonData(null);
}
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
// Fetch when the school set changes, but only for schools we don't already
// have data for. This skips the refetch of SSR-rendered data on load AND
// avoids a network call when a school is merely removed. A ref holds the
// latest data so the effect can read it without re-running on every fetch.
//
// Correctness note: we must NOT null the data on a transient empty urnKey.
// On mount the basket is empty for a beat before it hydrates from the URL,
// and blanking here (then skipping the refetch because SSR "covers" the set)
// was leaving the page empty on refresh. The render already shows the empty
// state whenever `selectedSchools` is empty, so stale data for deselected
// schools is harmless — it's simply unused.
const comparisonDataRef = useRef(comparisonData);
comparisonDataRef.current = comparisonData;
// Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => {
useEffect(() => {
if (!isInitialized || !urnKey) return;
const have = comparisonDataRef.current ?? {};
const covered = urnKey.split(',').every((urn) => have[urn] != null);
if (covered) return;
fetchComparison(urnKey, { cache: 'no-store' })
.then((data) => {
setComparisonData(data.comparison);
setNationalAverages(data.national_averages);
setBenchmarks(data.benchmarks);
})
.catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the page — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
});
}, [urnKey, isInitialized]);
const primarySchools = selectedSchools.filter((school) => {
const info = comparisonData?.[school.urn]?.school_info;
if (info?.attainment_8_score != null) return 'secondary';
if (info?.rwm_expected_pct != null) return 'primary';
// Fallback: check yearly data
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
return 'primary';
};
const hasPrimaryData =
info?.rwm_expected_pct != null ||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
if (hasPrimaryData) return true;
return school.phase?.toLowerCase().includes('primary') || false;
});
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
const secondarySchools = selectedSchools.filter((school) => {
const info = comparisonData?.[school.urn]?.school_info;
const hasSecondaryData =
info?.attainment_8_score != null ||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
if (hasSecondaryData) return true;
return school.phase?.toLowerCase().includes('secondary') || false;
});
// Auto-select tab with more schools and sync the metric to match the detected phase.
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
// but the shortlisted schools are secondary — the phase tab switches but the metric
// needs to follow, otherwise all secondary cards show "" for a primary-only field.
// Auto-select tab with more schools and sync the metric to match the phase.
useEffect(() => {
if (!comparisonData || selectedSchools.length === 0) return;
if (phaseLockedByUser.current) return;
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
setComparePhase(newPhase);
// Only reset the metric when it doesn't belong to the newly detected phase.
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
const metricFitsPhase = metrics.some(
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
);
if (!metricFitsPhase) {
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
@@ -158,29 +178,24 @@ export function ComparisonView({
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
phaseLockedByUser.current = true;
setComparePhase(phase);
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
setSelectedMetric(defaultMetric);
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
};
// compare_viewed: fire once after the page has its first selection.
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
// sent only when there's actual content to view, not for empty arrivals.
const compareViewedRef = useRef(false);
useEffect(() => {
if (compareViewedRef.current) return;
if (selectedSchools.length === 0) return;
compareViewedRef.current = true;
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
const primaryCount = selectedSchools.filter((s) =>
s.phase?.toLowerCase().includes('primary'),
).length;
const secondaryCount = selectedSchools.length - primaryCount;
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
const phaseMix =
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
}, [selectedSchools]);
const handleMetricChange = (metric: string) => {
track('compare_metric_changed', { metric, phase: comparePhase });
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => {
removeSchool(urn);
track('compare_school_removed', { urn, from: 'compare' });
@@ -191,21 +206,22 @@ export function ComparisonView({
const count = selectedSchools.length;
const shareData = {
title: 'School comparison · SchoolCompare',
text: count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
text:
count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
url,
};
// Prefer the native share sheet on platforms that support it (iOS / Android).
// canShare is feature-detected because Safari iOS exposes share() but
// some configurations refuse the payload.
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
if (
typeof navigator !== 'undefined' &&
navigator.share &&
(!navigator.canShare || navigator.canShare(shareData))
) {
try {
await navigator.share(shareData);
track('compare_shared', { method: 'native', school_count: count });
return;
} catch (err) {
// User cancelled — bail silently. Any other error falls through to clipboard.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
@@ -214,27 +230,22 @@ export function ComparisonView({
track('compare_shared', { method: 'clipboard', school_count: count });
setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ }
} catch {
/* fallback: do nothing */
}
};
const isPrimary = comparePhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
const activeSchools = isPrimary ? primarySchools : secondarySchools;
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
// No schools selected
if (selectedSchools.length === 0) {
return (
<div className={styles.container}>
<header className={styles.header}>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
Add schools to your comparison basket to see side-by-side performance data
Add schools to your comparison basket to see them side by side inspection results,
academics, admissions and community.
</p>
</header>
@@ -252,39 +263,46 @@ export function ComparisonView({
);
}
// Build filtered comparison data for active phase
// Build filtered comparison data for the active phase
const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) {
activeSchools.forEach(s => {
activeSchools.forEach((s) => {
if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn];
}
});
}
// Get years for table
const years =
Object.keys(activeComparisonData).length > 0
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: [];
const hasData = Object.keys(activeComparisonData).length > 0;
return (
<div className={styles.container}>
{/* Header */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
{selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
each number anchored against the England average so you can tell at a glance
what&apos;s typical and what stands out.
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<div className={styles.headerActions}>
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
+ Add School
</button>
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width="16"
height="16"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
{shareConfirm ? 'Copied!' : 'Share'}
</button>
</div>
@@ -292,20 +310,22 @@ export function ComparisonView({
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
{secondarySchools.length > 0 && primarySchools.length > 0 && (
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
)}
{activeSchools.length === 0 ? (
<EmptyState
@@ -318,184 +338,86 @@ export function ComparisonView({
/>
) : (
<>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
{optgroups.map(({ label, category }) => {
const groupMetrics = filteredMetrics.filter(m => m.category === category);
if (groupMetrics.length === 0) return null;
return (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
</section>
{/* Progress score explanation */}
{selectedMetric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
</p>
)}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolCard}
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
>
<button
onClick={() => handleRemoveSchool(school.urn)}
className={styles.removeButton}
aria-label={`Remove ${school.school_name}`}
title="Remove from comparison"
>
×
</button>
<h2 className={styles.schoolName}>
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
</h2>
<div className={styles.schoolMeta}>
{school.local_authority && (
<span className={styles.metaItem}>{school.local_authority}</span>
)}
{school.school_type && (
<span className={styles.metaItem}>{school.school_type}</span>
)}
</div>
{/* Latest metric value */}
{activeComparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
<span
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
background: CHART_COLORS[index % CHART_COLORS.length],
marginRight: '0.4rem',
verticalAlign: 'middle',
}}
/>
{(() => {
const yearlyData = activeComparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1];
const value = latestData[selectedMetric as keyof typeof latestData];
if (value === null || value === undefined) return '-';
if (selectedMetric.includes('progress')) {
return formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
return formatPercentage(value as number);
} else {
return typeof value === 'number' ? value.toFixed(1) : String(value);
}
})()}
</div>
</div>
)}
</div>
))}
</div>
</section>
{/* Comparison Chart */}
{Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric}
metricLabel={metricLabel}
{/* Sticky school bar — column identity while scrolling */}
<div className={styles.schoolBar} aria-label="Schools in this comparison">
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolChip}
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span className={styles.chipText}>
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
<span className={styles.chipNameFull}>{school.school_name}</span>
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a>
<span className={styles.chipMeta}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
</span>
</span>
<button
onClick={() => handleRemoveSchool(school.urn)}
className={styles.chipRemove}
aria-label={`Remove ${school.school_name}`}
title="Remove from comparison"
>
×
</button>
</div>
</section>
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
))}
</div>
{/* Comparison Table */}
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
<table className={styles.comparisonTable}>
<thead>
<tr>
<th>Year</th>
{activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
{activeSchools.map((school) => {
const schoolData = activeComparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
{hasData && (
<>
<CompareAtAGlance
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<TrendsExplorer
schools={activeSchools}
data={activeComparisonData}
metrics={metrics}
metric={selectedMetric}
onMetricChange={setSelectedMetric}
isPrimaryPhase={isPrimary}
nationalAverages={nationalAverages}
/>
const yearData = schoolData.yearly_data.find((d) => d.year === year);
if (!yearData) return <td key={school.urn}>-</td>;
const value = yearData[selectedMetric as keyof typeof yearData];
if (value === null || value === undefined) {
return <td key={school.urn}>-</td>;
}
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
displayValue = formatPercentage(value as number);
} else {
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
}
return <td key={school.urn}>{displayValue}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className={styles.footnote}>
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
outcomes, DfE school admissions data, school census. England averages for test
results are official DfE figures; other benchmarks are state-school averages
computed from our dataset. Following DfE practice, figures based on 5 or fewer
pupils are suppressed and shown as &quot;no data&quot;.
</p>
</>
)}
</>
)}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
+102
View File
@@ -0,0 +1,102 @@
.row {
margin: 1.1rem 0 1.6rem;
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.title {
font-weight: 600;
font-size: 0.95rem;
}
.headNote {
font-size: 0.8rem;
color: var(--text-muted);
}
.strip {
position: relative;
height: 34px;
margin-top: 0.45rem;
}
.track {
position: absolute;
left: 0;
right: 0;
top: 15px;
height: 4px;
border-radius: 2px;
background: var(--bg-secondary);
}
.anchorTick {
position: absolute;
top: 4px;
width: 2px;
height: 26px;
background: var(--text-muted);
}
.anchorLabel {
position: absolute;
top: -14px;
transform: translateX(-50%);
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.point {
position: absolute;
top: 9px;
width: 16px;
height: 16px;
border-radius: 50%;
transform: translateX(-50%);
border: 2px solid var(--bg-card);
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
}
.pointLabel {
position: absolute;
top: 27px;
transform: translateX(-50%);
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.pointLabelAbove {
top: -6px;
}
@media (max-width: 760px) {
.row {
margin: 0.9rem 0 1.3rem;
}
.title {
font-size: 0.82rem;
}
.strip {
height: 32px;
}
.point {
width: 14px;
height: 14px;
}
.pointLabel {
font-size: 0.64rem;
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* DotStrip — the compare screen's signature element: one measure per strip,
* every school's dot on a shared track, anchored by a grey England-average
* tick so "right of the tick = above average" needs no domain knowledge.
*/
'use client';
import { stripPositions } from '@/lib/compareLogic';
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
import styles from './DotStrip.module.css';
export interface DotStripProps {
label: string;
/** One value per school; index = the school's chart-colour index. */
values: Array<number | null>;
schoolNames: string[];
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
* benchmark isn't available — the caller should say why in `headNote`. */
anchor?: { value: number; label: string } | null;
min?: number;
max?: number;
unit?: string;
/** Tooltip on the measure label (plain-English definition). */
tip?: string;
/** Small note on the right of the header row (e.g. the tick legend). */
headNote?: string;
}
export function DotStrip({
label,
values,
schoolNames,
anchor = null,
min = 0,
max = 100,
unit = '%',
tip,
headNote,
}: DotStripProps) {
const points = stripPositions(values, min, max);
const span = max - min;
const anchorPos =
anchor != null
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
: null;
const ariaParts = [
anchor ? `${anchor.label}` : null,
...points.map(
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
),
].filter(Boolean);
return (
<div className={styles.row}>
<div className={styles.head}>
<span className={styles.title} title={tip}>
{label}
</span>
{headNote && <span className={styles.headNote}>{headNote}</span>}
</div>
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
<div className={styles.track} />
{anchorPos != null && anchor && (
<>
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
{anchor.label}
</span>
</>
)}
{points.map((p) => (
<span key={p.schoolIndex}>
<span
className={styles.point}
style={{
left: `${p.pos}%`,
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
}}
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
/>
<span
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
style={{
left: `${p.pos}%`,
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
}}
>
{p.value}
</span>
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
.moreMeasures {
margin-top: 0.5rem;
border-top: 1px solid var(--border-light);
padding-top: 0.75rem;
}
.moreMeasures summary {
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
color: var(--accent-coral-dark);
}
.stripNote {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
@@ -0,0 +1,294 @@
/**
* How children do academically — tier-1 dot strips anchored on official
* England averages, tier-2 "More measures" one tap away, equity row against
* the computed state-school benchmark. Copy verbatim from the reviewed
* mockups; teacher-assessed measures are labelled as such.
*/
'use client';
import { latestValues, verdict } from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
import styles from './CompareAcademics.module.css';
interface StripSpec {
label: string;
metric: string;
anchorKey?: string;
tip?: string;
min?: number;
max?: number;
unit?: string;
}
const TIER1_PRIMARY: StripSpec[] = [
{
label: 'Reading, writing & maths — expected standard',
metric: 'rwm_expected_pct',
anchorKey: 'rwm_expected_pct',
tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.',
},
{ label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' },
{
label: 'Writing (teacher-assessed)',
metric: 'writing_expected_pct',
anchorKey: 'writing_expected_pct',
tip: 'Writing is assessed by teachers, not tested.',
},
{ label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' },
{
label: 'Working at a higher standard than expected',
metric: 'rwm_high_pct',
anchorKey: 'rwm_high_pct',
tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.',
},
];
const TIER2_PRIMARY: StripSpec[] = [
{
label: 'Grammar, punctuation & spelling — expected standard',
metric: 'gps_expected_pct',
anchorKey: 'gps_expected_pct',
},
{
label: 'Science — expected standard (teacher-assessed)',
metric: 'science_expected_pct',
anchorKey: 'science_expected_pct',
tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.',
},
{
label: 'Average scaled score — reading',
metric: 'reading_avg_score',
anchorKey: 'reading_avg_score',
min: 100,
max: 120,
unit: '',
},
{
label: 'Average scaled score — maths',
metric: 'maths_avg_score',
anchorKey: 'maths_avg_score',
min: 100,
max: 120,
unit: '',
},
{
label: 'Average scaled score — grammar, punctuation & spelling',
metric: 'gps_avg_score',
anchorKey: 'gps_avg_score',
min: 100,
max: 120,
unit: '',
},
];
function Strip({
spec,
data,
urns,
schoolNames,
national,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
}) {
const values = latestValues(data, urns, spec.metric).map((v) =>
v != null ? Math.round(v) : null,
);
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor =
anchorValue != null
? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` }
: null;
if (values.every((v) => v == null)) return null;
return (
<DotStrip
label={spec.label}
values={values}
schoolNames={schoolNames}
anchor={anchor}
min={spec.min ?? 0}
max={spec.max ?? 100}
unit={spec.unit ?? '%'}
tip={spec.tip}
/>
);
}
export function CompareAcademics({
schools,
data,
nationalAverages,
benchmarks,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = latestValues(data, urns, 'attainment_8_score');
const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string;
}
return null;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
return (
<Section
title="How students do academically"
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally — the wording is DfE's own banding."
>
<SectionGrid schools={schools}>
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? (
<>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
{att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
))}
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{banding[i] ? (
<Chip
tone={
/well above|above/i.test(banding[i] as string)
? 'good'
: /well below|below/i.test(banding[i] as string)
? 'warn'
: 'neutral'
}
>
{banding[i]}
</Chip>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
))}
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
Grade 5+ in English &amp; maths
</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
</Cell>
))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
</Cell>
))}
</SectionGrid>
</Section>
);
}
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
return (
<Section
title="How children do academically"
how="Results from national tests and teacher assessments at the end of Year 6 — writing is assessed by teachers, not tested. Each line runs from 0100%; the grey tick marks the England average, so dots to its right are above average."
>
<div className={s.card}>
{TIER1_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
/>
))}
<details className={styles.moreMeasures}>
<summary>More measures grammar, punctuation &amp; spelling, science, average scaled scores</summary>
{TIER2_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
/>
))}
<p className={styles.stripNote}>
The scaled-score strips show the 100120 window of the full 80120 range; 100 is the
expected standard. Where an England tick is missing, the official figure isn&apos;t in
our dataset yet.
</p>
</details>
</div>
{disadvantaged.some((v) => v != null) && (
<SectionGrid schools={schools}>
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
Children from lower-income families
</RowLabel>
{schools.map((school, i) => {
const value = disadvantaged[i];
return (
<Cell key={school.urn} school={school} index={i}>
{value != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}%
</span>{' '}
{disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
`Well above the ${Math.round(disadvantagedAnchor)}% state-school average`}
{verdict(value, disadvantagedAnchor, 5) === 'close' &&
`Around the ${Math.round(disadvantagedAnchor)}% state-school average`}
{verdict(value, disadvantagedAnchor, 5) === 'below' &&
`Below the ${Math.round(disadvantagedAnchor)}% state-school average`}
</Chip>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</SectionGrid>
)}
</Section>
);
}
@@ -0,0 +1,130 @@
/**
* 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, Measure, 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}>
<Measure
tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place."
label="Interest in the school"
>
{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>
);
})}
</Measure>
<Measure label="First-choice families offered a place">
{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>
);
})}
</Measure>
<Measure label="What this means">
{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>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,193 @@
/**
* 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, Measure, 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,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const urns = schools.map((school) => school.urn);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : 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}>
<Measure label="Latest Ofsted inspection">
{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 === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell>
);
})}
</Measure>
<Measure
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).'
}
label={isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
>
{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>
);
})}
</Measure>
<Measure label="Getting a place">
{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>
);
})}
</Measure>
<Measure label="Size">
{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>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,197 @@
/**
* 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, Measure, 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,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : 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) => {
const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null;
if (value == null || anchor == null) return null;
const v = verdict(value, anchor, 3);
return (
<Chip tone="neutral">
{v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`}
{v === 'close' && `About the state-school average (${Math.round(anchor)}%)`}
{v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`}
</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}>
<Measure label="Pupils on roll">
{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>
);
})}
</Measure>
<Measure label="Girls / boys">
{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>
);
})}
</Measure>
<Measure
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."
label="Free school meals"
>
{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>
);
})}
</Measure>
<Measure
tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset."
label="English as an additional language"
>
{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>
);
})}
</Measure>
<Measure
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."
label="Extra learning support (SEN)"
>
{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>
);
})}
</Measure>
<Measure label="Faith character">
{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>
);
})}
</Measure>
<Measure label="Ages">
{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>
);
})}
</Measure>
<Measure label="Run by">
{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>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,237 @@
/**
* 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]> = [
['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>
);
}
@@ -0,0 +1,77 @@
.explore {
margin-top: 1rem;
}
.explore summary {
cursor: pointer;
font-weight: 600;
color: var(--accent-coral-dark);
padding: 0.85rem 1.1rem;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.explore[open] summary {
border-radius: 8px 8px 0 0;
}
.inner {
border: 1px solid var(--border-light);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--bg-card);
padding: 1.25rem 1.5rem;
}
.picker {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.picker label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
}
.picker select {
font-family: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--border-light);
background: var(--bg-card);
color: var(--text-primary);
max-width: 100%;
}
.desc {
font-size: 0.78rem;
color: var(--text-muted);
}
.progressNote {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0 0 1rem;
}
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
its container's height — which must be *definite*. A min-height alone does
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
to its ~150px default (a squashed sliver). Give it a real height. */
.chartBox {
height: 420px;
}
@media (max-width: 640px) {
/* Taller on mobile: the mobile-only school chips sit above the canvas and
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
.chartBox {
height: 360px;
}
}
@@ -0,0 +1,128 @@
/**
* Explore trends — the full grouped metric catalogue (nothing from the old
* compare page is lost; spec §4's tier 3) driving the year-by-year chart with
* its England reference line. Matches the mockup: a measure picker and the
* chart only (no data table).
*/
'use client';
import dynamic from 'next/dynamic';
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
import { track } from '@/lib/analytics';
import { Section } from './sectionShared';
import styles from './TrendsExplorer.module.css';
const ComparisonChart = dynamic(
() => import('../ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category);
export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category);
export function TrendsExplorer({
schools,
data,
metrics,
metric,
onMetricChange,
isPrimaryPhase,
nationalAverages,
}: {
schools: School[];
data: Record<string, ComparisonData>;
metrics: MetricDefinition[];
/** Controlled: the page owns the metric so the URL contract survives. */
metric: string;
onMetricChange: (metric: string) => void;
isPrimaryPhase: boolean;
nationalAverages?: NationalAverages;
}) {
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
const metricDef = metrics.find((m) => m.key === metric);
const metricLabel = metricDef?.label || metric;
const nationalByYear: Record<number, number | null> = {};
for (const entry of nationalAverages?.by_year ?? []) {
const block = isPrimaryPhase ? entry.primary : entry.secondary;
nationalByYear[entry.year] = block?.[metric] ?? null;
}
const handleMetricChange = (next: string) => {
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
onMetricChange(next);
};
return (
<Section
title="Explore trends"
how="The full year-by-year explorer for any measure, with the England average as a dashed reference line where official figures exist."
>
<details className={styles.explore} open>
<summary>Year-by-year trends</summary>
<div className={styles.inner}>
<div className={styles.picker}>
<label htmlFor="trends-metric-select">Measure:</label>
<select
id="trends-metric-select"
value={metric}
onChange={(e) => handleMetricChange(e.target.value)}
>
{optgroups.map(({ label, category }) => {
const group = filteredMetrics.filter((m) => m.category === category);
if (group.length === 0) return null;
return (
<optgroup key={category} label={label}>
{group.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
</option>
))}
</optgroup>
);
})}
</select>
{metricDef?.description && <span className={styles.desc}>{metricDef.description}</span>}
</div>
{metric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the
national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline).
</p>
)}
<div className={styles.chartBox}>
<ComparisonChart
comparisonData={data}
schools={schools}
metric={metric}
metricLabel={metricLabel}
nationalByYear={nationalByYear}
/>
</div>
</div>
</details>
</Section>
);
}
@@ -0,0 +1,264 @@
/* 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;
}
/* Mobile base: each measure is a card; each cell is a school row led by a
colour dot + short name. `display: contents` at ≥761px dissolves the card
back into the shared grid. */
.measure {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 12px;
box-shadow: var(--shadow-soft);
padding: 0.75rem 0.85rem;
margin-bottom: 0.6rem;
}
.rowLabel {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0 0 0.1rem;
}
.cell {
display: flex;
align-items: baseline;
gap: 0.35rem 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--border-light);
margin-top: 0.5rem;
font-size: 0.95rem;
}
/* The school name gets its own full-width line above the value — real
school names are long and varied, so a fixed-width name column truncated
them ("Our Lady Queen of H…") or crowded the value. */
.cellTag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-basis: 100%;
font-size: 0.8rem;
font-weight: 600;
color: var(--sc, var(--text-secondary));
margin-bottom: 0.15rem;
}
.cellDot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--dot, var(--text-muted));
flex: none;
}
.big {
font-size: 1.05rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.small {
display: block;
flex-basis: 100%;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0;
}
.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;
flex-basis: 100%;
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;
}
/* Dissolve the per-measure card so its label + cells become grid items of
.grid, keeping columns aligned across every measure. */
.measure {
display: contents;
}
.cellTag {
display: none;
}
.rowLabel {
color: var(--text-secondary);
padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border-light);
}
.cell {
display: block;
padding: 0.85rem 0.25rem;
border-top: none;
border-bottom: 1px solid var(--border-light);
margin-top: 0;
}
.big {
font-size: 1.35rem;
}
.small {
flex-basis: auto;
padding-left: 0;
margin-top: 0.1rem;
}
}
@@ -0,0 +1,130 @@
/**
* 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_COLORS, CHART_TEXT_COLORS, shortName } 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>
);
}
/**
* One measure = its row label plus a cell per school. `display: contents` on
* desktop (see CSS) makes these flow into the section grid as if this wrapper
* weren't here, keeping columns aligned across measures; on mobile the wrapper
* becomes a card so each measure reads as its own block.
*/
export function Measure({
label,
tip,
children,
}: {
label: ReactNode;
tip?: string;
children: ReactNode;
}) {
return (
<div className={styles.measure}>
<RowLabel tip={tip}>{label}</RowLabel>
{children}
</div>
);
}
export function Cell({
school,
index,
children,
}: {
school: School;
index: number;
children: ReactNode;
}) {
return (
<div
className={styles.cell}
style={
{
'--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length],
'--dot': CHART_COLORS[index % CHART_COLORS.length],
} as CSSProperties
}
>
{/* Mobile-only per-school tag (dot + short name); hidden on desktop,
where the column header identifies the school. */}
<span className={styles.cellTag}>
<span className={styles.cellDot} aria-hidden="true" />
{shortName(school.school_name)}
</span>
{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;