/** * 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, type CSSProperties } 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, shortName } 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); } } // Re-seed when a client-side navigation lands on a different ?urns= set // (initialUrns/initialData are new props on the same component instance). }, [isInitialized, initialUrns.join(',')]); // 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 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; useEffect(() => { if (!isInitialized || !urnKey) return; const have = comparisonDataRef.current ?? {}; const covered = urnKey.split(',').every((urn) => have[urn] != null); if (covered) return; // Guard against out-of-order responses: while the basket hydrates from // localStorage it can transiently hold a DIFFERENT school set than the // URL, firing a fetch for schools the user is no longer comparing. That // stale response must not replace data for the current set — it blanked // every section until a hard refresh. Cleanup marks the run cancelled // when urnKey moves on, so only the current selection's response is // applied (replacing the map keeps it bounded and guarantees a re-added // school is refetched fresh rather than served a lingering old entry). let cancelled = false; fetchComparison(urnKey, { cache: 'no-store' }) .then((data) => { if (cancelled) return; 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); }); return () => { cancelled = true; }; }, [urnKey, isInitialized]); const primarySchools = selectedSchools.filter((school) => { const info = comparisonData?.[school.urn]?.school_info; 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 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 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'); } // selectedSchools is a dep because the basket hydrates after mount: the // first run sees an empty basket and bails, so it must re-fire when the // schools arrive. primarySchools/secondarySchools/metrics/selectedMetric // are intentionally omitted (derived or would cause loops). }, [comparisonData, selectedSchools]); // 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. On desktop it shares the sections' grid template (via --school-count) so each chip sits exactly over the column it labels. */}
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
Comparing {activeSchools.length} {comparePhase} school {activeSchools.length === 1 ? '' : 's'}
{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)} />
); }