/** * 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 { useComparison } from '@/hooks/useComparison'; import { SchoolSearchModal } from './SchoolSearchModal'; import { EmptyState } from './EmptyState'; 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'; interface ComparisonViewProps { initialData: Record | null; initialNationalAverages?: NationalAverages; initialBenchmarks?: Benchmarks; initialUrns: number[]; metrics: MetricDefinition[]; selectedMetric: string; } export function ComparisonView({ initialData, initialNationalAverages, initialBenchmarks, initialUrns, metrics, selectedMetric: initialMetric, }: ComparisonViewProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); 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( initialNationalAverages, ); const [benchmarks, setBenchmarks] = useState(initialBenchmarks); const [shareConfirm, setShareConfirm] = useState(false); const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); // Tracks whether the user has explicitly clicked a phase tab. // While true, auto-phase detection is suppressed so manual selections aren't overridden. const phaseLockedByUser = useRef(false); // Seed context from the URL on mount. An explicit ?urns=… (e.g. a link a // parent shared with their partner) always wins over this visitor's stored // selection — otherwise the recipient silently sees their own old schools. // The replacement is then persisted like any other selection change. useEffect(() => { if (!isInitialized) return; if (initialUrns.length > 0 && initialData) { const urlSchools = initialUrns .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)); if (urlSchools.length > 0 && !sameSet) { replaceSchools(urlSchools); } } }, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps 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 params = new URLSearchParams(searchParams); if (urnKey) { params.set('urns', urnKey); } else { params.delete('urns'); } params.set('metric', selectedMetric); const newUrl = `${pathname}?${params.toString()}`; router.replace(newUrl, { scroll: false }); }, [urnKey, selectedMetric, pathname, searchParams, router]); // Fetch only when the school set changes. The very first run is skipped // when the SSR payload already covers the current set — no double-fetch // of data the server just rendered. const firstFetchRef = useRef(true); useEffect(() => { if (!urnKey) { setComparisonData(null); setNationalAverages(undefined); setBenchmarks(undefined); return; } if (firstFetchRef.current) { firstFetchRef.current = false; const ssrUrns = new Set(Object.keys(initialData ?? {})); const covered = urnKey.split(',').every((urn) => ssrUrns.has(urn)); if (covered && ssrUrns.size > 0) 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); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [urnKey]); // Classify schools by phase using comparison data const classifySchool = (school: School): 'primary' | 'secondary' => { 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) => 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'); // 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); const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES; const metricFitsPhase = metrics.some( (m) => m.key === selectedMetric && phaseCategories.includes(m.category), ); if (!metricFitsPhase) { setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); } }, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps const handlePhaseChange = (phase: 'primary' | 'secondary') => { phaseLockedByUser.current = true; setComparePhase(phase); setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); }; // compare_viewed: fire once after the page has its first selection. 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 secondaryCount = selectedSchools.length - primaryCount; const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed'; track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix }); }, [selectedSchools]); const handleRemoveSchool = (urn: number) => { removeSchool(urn); track('compare_school_removed', { urn, from: 'compare' }); }; const handleShare = async () => { const url = window.location.href; const count = selectedSchools.length; const shareData = { title: 'School comparison · SchoolCompare', text: count > 0 ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` : 'SchoolCompare', url, }; 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) { if ((err as DOMException)?.name === 'AbortError') return; } } try { await navigator.clipboard.writeText(url); track('compare_shared', { method: 'clipboard', school_count: count }); setShareConfirm(true); setTimeout(() => setShareConfirm(false), 2000); } catch { /* fallback: do nothing */ } }; const isPrimary = comparePhase === 'primary'; const activeSchools = isPrimary ? primarySchools : secondarySchools; if (selectedSchools.length === 0) { return (

Compare Schools

Add schools to your comparison basket to see them side by side — inspection results, academics, admissions and community.

setIsModalOpen(true), }} /> setIsModalOpen(false)} />
); } // Build filtered comparison data for the active phase const activeComparisonData: Record = {}; if (comparisonData) { activeSchools.forEach((s) => { if (comparisonData[s.urn]) { activeComparisonData[s.urn] = comparisonData[s.urn]; } }); } const hasData = Object.keys(activeComparisonData).length > 0; return (

Compare Schools

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

{/* Phase Tabs */} {secondarySchools.length > 0 && primarySchools.length > 0 && (
)} {activeSchools.length === 0 ? ( setIsModalOpen(true), }} /> ) : ( <> {/* Sticky school bar — column identity while scrolling */}
{activeSchools.map((school, index) => (
))}
{hasData && ( <>

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

)} )} setIsModalOpen(false)} />
); }