feat(compare): parent-first compare screen assembly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-13 23:58:00 +01:00
co-authored by Claude Fable 5
parent 519584f34b
commit 2155256177
4 changed files with 240 additions and 663 deletions
+4 -2
View File
@@ -16,8 +16,10 @@ interface ComparePageProps {
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Compare Schools', title: 'Compare Schools',
description: 'Compare KS2 performance across multiple primary schools in England', description:
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance', 'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
keywords:
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
}; };
// Dynamic via searchParams; remove force-dynamic so internal data fetches // Dynamic via searchParams; remove force-dynamic so internal data fetches
+71 -385
View File
@@ -28,8 +28,15 @@
color: var(--text-secondary, #5c564d); color: var(--text-secondary, #5c564d);
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
max-width: 60ch;
} }
.headerActions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Phase Tabs */ /* Phase Tabs */
.phaseTabs { .phaseTabs {
@@ -72,408 +79,87 @@
background: var(--accent-coral-darker, #9c3f26); background: var(--accent-coral-darker, #9c3f26);
} }
/* Metric Selector */ /* Sticky school bar — column identity while scrolling; horizontal scroll on
.metricSelector { narrow screens */
background: var(--bg-card, white); .schoolBar {
border: 1px solid var(--border-color, #e5dfd5); position: sticky;
border-radius: 12px; top: 0;
padding: 1.5rem; z-index: 10;
margin-bottom: 2rem; background: var(--bg-primary, #faf7f2);
display: flex; display: flex;
align-items: center; gap: 0.75rem;
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 {
overflow-x: auto; overflow-x: auto;
max-width: 100%; padding: 0.75rem 0;
margin-top: 1rem; border-bottom: 1px solid var(--border-light, #e5dfd5);
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Right-edge fade so phone users see the comparison table scrolls. .schoolChip {
Otherwise the wider-than-viewport table silently clips. */ flex: 1 1 0;
@media (max-width: 640px) { min-width: 180px;
.tableWrapper { background: var(--bg-card, white);
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); border: 1px solid var(--border-light, #e5dfd5);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); 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 { .chipDot {
width: 100%; width: 11px;
border-collapse: separate; height: 11px;
border-spacing: 0; border-radius: 50%;
font-size: 0.9375rem; flex: none;
} }
.comparisonTable thead { .chipText {
background: var(--bg-secondary, #f3ede4); min-width: 0;
} }
.comparisonTable th { .chipName {
padding: 1rem; display: block;
text-align: left;
font-weight: 600; font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5); text-decoration: none;
background: var(--bg-secondary, #f3ede4); }
.chipName:hover {
color: var(--accent-coral-dark, #b04a2e);
}
.chipMeta {
display: block;
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; overflow: hidden;
font-size: 0.75rem; text-overflow: ellipsis;
letter-spacing: 0.05em;
} }
.comparisonTable td { .chipRemove {
padding: 1rem; margin-left: auto;
border-bottom: 1px solid var(--border-color, #e5dfd5); border: none;
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 {
background: var(--bg-secondary, #f3ede4); 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 { .footnote {
border-bottom: none; font-size: 0.78rem;
} color: var(--text-muted, #6d685f);
margin-top: 2.5rem;
.comparisonTable tbody tr:hover { border-top: 1px solid var(--border-light, #e5dfd5);
background: var(--bg-secondary, #f3ede4); padding-top: 1rem;
} max-width: 75ch;
.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;
}
.header h1 {
font-size: 1.75rem;
}
.metricSelector {
flex-direction: column;
align-items: stretch;
padding: 1rem;
border-radius: 8px;
}
.metricSelect {
max-width: 100%;
}
.schoolsGrid {
grid-template-columns: 1fr;
}
.chartSection,
.tableSection {
padding: 1rem;
border-radius: 8px;
}
.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;
}
} }
+159 -270
View File
@@ -1,47 +1,38 @@
/** /**
* ComparisonView Component * ComparisonView — the parent-first compare screen: a sticky school bar and
* Client-side comparison interface with phase tabs, charts, and tables * 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'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from '@/hooks/useComparison';
const ComparisonChart = dynamic(
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
import { SchoolSearchModal } from './SchoolSearchModal'; import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton'; import { CompareAtAGlance } from './compare/CompareAtAGlance';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types'; import { CompareOfsted } from './compare/CompareOfsted';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils'; 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 } from '@/lib/utils';
import { fetchComparison } from '@/lib/api'; import { fetchComparison } from '@/lib/api';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './ComparisonView.module.css'; 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 { interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null; initialData: Record<string, ComparisonData> | null;
initialUrns: number[]; initialUrns: number[];
@@ -58,11 +49,13 @@ export function ComparisonView({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison(); const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric); const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData); const [comparisonData, setComparisonData] = useState(initialData);
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>();
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>();
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Tracks whether the user has explicitly clicked a phase tab. // Tracks whether the user has explicitly clicked a phase tab.
@@ -77,18 +70,18 @@ export function ComparisonView({
if (!isInitialized) return; if (!isInitialized) return;
if (initialUrns.length > 0 && initialData) { if (initialUrns.length > 0 && initialData) {
const urlSchools = initialUrns 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)); .filter((info): info is NonNullable<typeof info> => Boolean(info));
const sameSet = const sameSet =
urlSchools.length === selectedSchools.length && 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) { if (urlSchools.length > 0 && !sameSet) {
replaceSchools(urlSchools); replaceSchools(urlSchools);
} }
} }
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps }, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync URL with selected schools // Sync URL with selected schools + metric, and (re)fetch the comparison.
useEffect(() => { useEffect(() => {
const urns = selectedSchools.map((s) => s.urn).join(','); const urns = selectedSchools.map((s) => s.urn).join(',');
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
@@ -104,20 +97,23 @@ export function ComparisonView({
const newUrl = `${pathname}?${params.toString()}`; const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false }); router.replace(newUrl, { scroll: false });
// Fetch comparison data
if (selectedSchools.length > 0) { if (selectedSchools.length > 0) {
fetchComparison(urns, { cache: 'no-store' }) fetchComparison(urns, { cache: 'no-store' })
.then((data) => { .then((data) => {
setComparisonData(data.comparison); setComparisonData(data.comparison);
setNationalAverages(data.national_averages);
setBenchmarks(data.benchmarks);
}) })
.catch((err) => { .catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather // Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't // than blanking the page — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at. // destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err); console.error('Failed to fetch comparison:', err);
}); });
} else { } else {
setComparisonData(null); setComparisonData(null);
setNationalAverages(undefined);
setBenchmarks(undefined);
} }
}, [selectedSchools, selectedMetric, pathname, searchParams, router]); }, [selectedSchools, selectedMetric, pathname, searchParams, router]);
@@ -128,27 +124,22 @@ export function ComparisonView({
if (info?.rwm_expected_pct != null) return 'primary'; if (info?.rwm_expected_pct != null) return 'primary';
// Fallback: check yearly data // Fallback: check yearly data
const yearlyData = comparisonData?.[school.urn]?.yearly_data; const yearlyData = comparisonData?.[school.urn]?.yearly_data;
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary'; if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary';
return 'primary'; return 'primary';
}; };
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary'); const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary');
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary'); const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary');
// Auto-select tab with more schools and sync the metric to match the detected phase. // Auto-select tab with more schools and sync the metric to match the 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.
useEffect(() => { useEffect(() => {
if (!comparisonData || selectedSchools.length === 0) return; if (!comparisonData || selectedSchools.length === 0) return;
if (phaseLockedByUser.current) return; if (phaseLockedByUser.current) return;
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary'; const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
setComparePhase(newPhase); 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 phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
const metricFitsPhase = metrics.some( const metricFitsPhase = metrics.some(
(m) => m.key === selectedMetric && phaseCategories.includes(m.category) (m) => m.key === selectedMetric && phaseCategories.includes(m.category),
); );
if (!metricFitsPhase) { if (!metricFitsPhase) {
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
@@ -158,29 +149,24 @@ export function ComparisonView({
const handlePhaseChange = (phase: 'primary' | 'secondary') => { const handlePhaseChange = (phase: 'primary' | 'secondary') => {
phaseLockedByUser.current = true; phaseLockedByUser.current = true;
setComparePhase(phase); setComparePhase(phase);
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
setSelectedMetric(defaultMetric);
}; };
// compare_viewed: fire once after the page has its first selection. // 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); const compareViewedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (compareViewedRef.current) return; if (compareViewedRef.current) return;
if (selectedSchools.length === 0) return; if (selectedSchools.length === 0) return;
compareViewedRef.current = true; 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 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 }); track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
}, [selectedSchools]); }, [selectedSchools]);
const handleMetricChange = (metric: string) => {
track('compare_metric_changed', { metric, phase: comparePhase });
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => { const handleRemoveSchool = (urn: number) => {
removeSchool(urn); removeSchool(urn);
track('compare_school_removed', { urn, from: 'compare' }); track('compare_school_removed', { urn, from: 'compare' });
@@ -191,21 +177,22 @@ export function ComparisonView({
const count = selectedSchools.length; const count = selectedSchools.length;
const shareData = { const shareData = {
title: 'School comparison · SchoolCompare', title: 'School comparison · SchoolCompare',
text: count > 0 text:
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` count > 0
: 'SchoolCompare', ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
url, url,
}; };
// Prefer the native share sheet on platforms that support it (iOS / Android). if (
// canShare is feature-detected because Safari iOS exposes share() but typeof navigator !== 'undefined' &&
// some configurations refuse the payload. navigator.share &&
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { (!navigator.canShare || navigator.canShare(shareData))
) {
try { try {
await navigator.share(shareData); await navigator.share(shareData);
track('compare_shared', { method: 'native', school_count: count }); track('compare_shared', { method: 'native', school_count: count });
return; return;
} catch (err) { } catch (err) {
// User cancelled — bail silently. Any other error falls through to clipboard.
if ((err as DOMException)?.name === 'AbortError') return; if ((err as DOMException)?.name === 'AbortError') return;
} }
} }
@@ -214,27 +201,22 @@ export function ComparisonView({
track('compare_shared', { method: 'clipboard', school_count: count }); track('compare_shared', { method: 'clipboard', school_count: count });
setShareConfirm(true); setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000); setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ } } catch {
/* fallback: do nothing */
}
}; };
const isPrimary = comparePhase === 'primary'; 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; 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) { if (selectedSchools.length === 0) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<header className={styles.header}> <header className={styles.header}>
<h1>Compare Schools</h1> <h1>Compare Schools</h1>
<p className={styles.subtitle}> <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> </p>
</header> </header>
@@ -252,39 +234,46 @@ export function ComparisonView({
); );
} }
// Build filtered comparison data for active phase // Build filtered comparison data for the active phase
const activeComparisonData: Record<string, ComparisonData> = {}; const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) { if (comparisonData) {
activeSchools.forEach(s => { activeSchools.forEach((s) => {
if (comparisonData[s.urn]) { if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn]; activeComparisonData[s.urn] = comparisonData[s.urn];
} }
}); });
} }
const hasData = Object.keys(activeComparisonData).length > 0;
// Get years for table
const years =
Object.keys(activeComparisonData).length > 0
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: [];
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Header */}
<header className={styles.header}> <header className={styles.header}>
<div className={styles.headerContent}> <div className={styles.headerContent}>
<div> <div>
<h1>Compare Schools</h1> <h1>Compare Schools</h1>
<p className={styles.subtitle}> <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> </p>
</div> </div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}> <div className={styles.headerActions}>
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary"> <button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
+ Add School + Add School
</button> </button>
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link"> <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'} {shareConfirm ? 'Copied!' : 'Share'}
</button> </button>
</div> </div>
@@ -292,20 +281,22 @@ export function ComparisonView({
</header> </header>
{/* Phase Tabs */} {/* Phase Tabs */}
<div className={styles.phaseTabs}> {secondarySchools.length > 0 && primarySchools.length > 0 && (
<button <div className={styles.phaseTabs}>
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`} <button
onClick={() => handlePhaseChange('primary')} className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
> onClick={() => handlePhaseChange('primary')}
Primary ({primarySchools.length}) >
</button> Primary ({primarySchools.length})
<button </button>
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`} <button
onClick={() => handlePhaseChange('secondary')} className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
> onClick={() => handlePhaseChange('secondary')}
Secondary ({secondarySchools.length}) >
</button> Secondary ({secondarySchools.length})
</div> </button>
</div>
)}
{activeSchools.length === 0 ? ( {activeSchools.length === 0 ? (
<EmptyState <EmptyState
@@ -318,184 +309,82 @@ export function ComparisonView({
/> />
) : ( ) : (
<> <>
{/* Metric Selector */} {/* Sticky school bar — column identity while scrolling */}
<section className={styles.metricSelector}> <div className={styles.schoolBar} aria-label="Schools in this comparison">
<label htmlFor="metric-select" className={styles.metricLabel}> {activeSchools.map((school, index) => (
Select Metric: <div
</label> key={school.urn}
<select className={styles.schoolChip}
id="metric-select" style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
value={selectedMetric} >
onChange={(e) => handleMetricChange(e.target.value)} <span
className={styles.metricSelect} className={styles.chipDot}
> style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
{optgroups.map(({ label, category }) => { aria-hidden="true"
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}
/> />
<span className={styles.chipText}>
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
{school.school_name}
</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> </div>
</section> ))}
) : activeSchools.length > 0 ? ( </div>
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Table */} {hasData && (
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && ( <>
<section className={styles.tableSection}> <CompareAtAGlance
<h2 className={styles.sectionTitle}>Detailed Comparison</h2> schools={activeSchools}
<div className={styles.tableWrapper}> data={activeComparisonData}
<table className={styles.comparisonTable}> nationalAverages={nationalAverages}
<thead> benchmarks={benchmarks}
<tr> />
<th>Year</th> <CompareOfsted schools={activeSchools} data={activeComparisonData} />
{activeSchools.map((school) => ( <CompareAcademics
<th key={school.urn}>{school.school_name}</th> schools={activeSchools}
))} data={activeComparisonData}
</tr> nationalAverages={nationalAverages}
</thead> benchmarks={benchmarks}
<tbody> />
{years.map((year) => ( <CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<tr key={year}> <CompareCommunity
<td className={styles.yearCell}>{formatAcademicYear(year)}</td> schools={activeSchools}
{activeSchools.map((school) => { data={activeComparisonData}
const schoolData = activeComparisonData[school.urn]; benchmarks={benchmarks}
if (!schoolData) return <td key={school.urn}>-</td>; />
<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); <p className={styles.footnote}>
if (!yearData) return <td key={school.urn}>-</td>; Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
outcomes, DfE school admissions data, school census. England averages for test
const value = yearData[selectedMetric as keyof typeof yearData]; results are official DfE figures; other benchmarks are state-school averages
computed from our dataset. Following DfE practice, figures based on 5 or fewer
if (value === null || value === undefined) { pupils are suppressed and shown as &quot;no data&quot;.
return <td key={school.urn}>-</td>; </p>
} </>
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>
)} )}
</> </>
)} )}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} /> <SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div> </div>
); );
@@ -7,7 +7,6 @@
'use client'; 'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { progressBand } from '@/lib/compareLogic'; import { progressBand } from '@/lib/compareLogic';
@@ -53,19 +52,20 @@ export function TrendsExplorer({
schools, schools,
data, data,
metrics, metrics,
initialMetric, metric,
onMetricChange,
isPrimaryPhase, isPrimaryPhase,
nationalAverages, nationalAverages,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
metrics: MetricDefinition[]; metrics: MetricDefinition[];
initialMetric: string; /** Controlled: the page owns the metric so the URL contract survives. */
metric: string;
onMetricChange: (metric: string) => void;
isPrimaryPhase: boolean; isPrimaryPhase: boolean;
nationalAverages?: NationalAverages; nationalAverages?: NationalAverages;
}) { }) {
const [metric, setMetric] = useState(initialMetric);
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category)); const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
@@ -88,7 +88,7 @@ export function TrendsExplorer({
const handleMetricChange = (next: string) => { const handleMetricChange = (next: string) => {
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' }); track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
setMetric(next); onMetricChange(next);
}; };
const ciKeys = PROGRESS_CI[metric]; const ciKeys = PROGRESS_CI[metric];