diff --git a/nextjs-app/app/compare/page.tsx b/nextjs-app/app/compare/page.tsx index 1a1ff38..31689a5 100644 --- a/nextjs-app/app/compare/page.tsx +++ b/nextjs-app/app/compare/page.tsx @@ -16,8 +16,10 @@ interface ComparePageProps { export const metadata: Metadata = { title: 'Compare Schools', - description: 'Compare KS2 performance across multiple primary schools in England', - keywords: 'school comparison, compare schools, KS2 comparison, primary school performance', + description: + '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 diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index b6f1af6..633ba12 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -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,87 @@ 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 */ +.schoolBar { + position: sticky; + top: 0; + 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); +} + +.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; -} - -.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; - } - - .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; - } +.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; } diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index e1f929d..4e26d89 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -1,47 +1,38 @@ /** - * 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 } 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 | null; initialUrns: number[]; @@ -58,11 +49,13 @@ 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(); + const [benchmarks, setBenchmarks] = useState(); const [shareConfirm, setShareConfirm] = useState(false); const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); // Tracks whether the user has explicitly clicked a phase tab. @@ -77,18 +70,18 @@ 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 => 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 + // Sync URL with selected schools + metric, and (re)fetch the comparison. useEffect(() => { const urns = selectedSchools.map((s) => s.urn).join(','); const params = new URLSearchParams(searchParams); @@ -104,20 +97,23 @@ export function ComparisonView({ const newUrl = `${pathname}?${params.toString()}`; router.replace(newUrl, { scroll: false }); - // Fetch comparison data if (selectedSchools.length > 0) { fetchComparison(urns, { 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 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. console.error('Failed to fetch comparison:', err); }); } else { setComparisonData(null); + setNationalAverages(undefined); + setBenchmarks(undefined); } }, [selectedSchools, selectedMetric, pathname, searchParams, router]); @@ -128,27 +124,22 @@ export function ComparisonView({ 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'; + if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary'; return 'primary'; }; - const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary'); - const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary'); + const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary'); + const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary'); - // 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 +149,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 +177,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 +201,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 (

Compare Schools

- 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.

@@ -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 = {}; 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 (
- {/* Header */}

Compare Schools

- 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's typical and what stands out.

-
+
@@ -292,20 +281,22 @@ export function ComparisonView({
{/* Phase Tabs */} -
- - -
+ {secondarySchools.length > 0 && primarySchools.length > 0 && ( +
+ + +
+ )} {activeSchools.length === 0 ? ( ) : ( <> - {/* Metric Selector */} -
- - - {currentMetricDef?.description && ( -

{currentMetricDef.description}

- )} -
- - {/* Progress score explanation */} - {selectedMetric.includes('progress') && ( -

- Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average. -

- )} - - {/* School Cards */} -
-
- {activeSchools.map((school, index) => ( -
- -

- {school.school_name} -

-
- {school.local_authority && ( - {school.local_authority} - )} - {school.school_type && ( - {school.school_type} - )} -
- - {/* Latest metric value */} - {activeComparisonData[school.urn] && ( -
-
{metricLabel}
- {/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */} -
- - {(() => { - 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); - } - })()} -
-
- )} -
- ))} -
-
- - {/* Comparison Chart */} - {Object.keys(activeComparisonData).length > 0 ? ( -
-

Performance Over Time

-
- + {activeSchools.map((school, index) => ( +
+
-
- ) : activeSchools.length > 0 ? ( -
- -
- ) : null} + ))} +
- {/* Comparison Table */} - {Object.keys(activeComparisonData).length > 0 && years.length > 0 && ( -
-

Detailed Comparison

-
- - - - - {activeSchools.map((school) => ( - - ))} - - - - {years.map((year) => ( - - - {activeSchools.map((school) => { - const schoolData = activeComparisonData[school.urn]; - if (!schoolData) return ; + {hasData && ( + <> + + + + + + - const yearData = schoolData.yearly_data.find((d) => d.year === year); - if (!yearData) return ; - - const value = yearData[selectedMetric as keyof typeof yearData]; - - if (value === null || value === undefined) { - return ; - } - - 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 ; - })} - - ))} - -
Year{school.school_name}
{formatAcademicYear(year)}---{displayValue}
-
-
+

+ 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 "no data". +

+ )} )} - {/* School Search Modal */} setIsModalOpen(false)} />
); diff --git a/nextjs-app/components/compare/TrendsExplorer.tsx b/nextjs-app/components/compare/TrendsExplorer.tsx index c4ffd64..6cd5a6e 100644 --- a/nextjs-app/components/compare/TrendsExplorer.tsx +++ b/nextjs-app/components/compare/TrendsExplorer.tsx @@ -7,7 +7,6 @@ 'use client'; -import { useState } from 'react'; import dynamic from 'next/dynamic'; import { progressBand } from '@/lib/compareLogic'; @@ -53,19 +52,20 @@ export function TrendsExplorer({ schools, data, metrics, - initialMetric, + metric, + onMetricChange, isPrimaryPhase, nationalAverages, }: { schools: School[]; data: Record; metrics: MetricDefinition[]; - initialMetric: string; + /** Controlled: the page owns the metric so the URL contract survives. */ + metric: string; + onMetricChange: (metric: string) => void; isPrimaryPhase: boolean; nationalAverages?: NationalAverages; }) { - const [metric, setMetric] = useState(initialMetric); - const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category)); @@ -88,7 +88,7 @@ export function TrendsExplorer({ const handleMetricChange = (next: string) => { track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' }); - setMetric(next); + onMetricChange(next); }; const ciKeys = PROGRESS_CI[metric];