/** * HomeView Component * Client-side home page view with search and filtering */ 'use client'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { FilterBar } from './FilterBar'; import { SchoolRow } from './SchoolRow'; import { SecondarySchoolRow } from './SecondarySchoolRow'; import { SchoolMap } from './SchoolMap'; import { EmptyState } from './EmptyState'; import { useComparisonContext } from '@/context/ComparisonContext'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api'; import type { SchoolsResponse, Filters, School } from '@/lib/types'; import { schoolUrl, buildOfstedListBadge } from '@/lib/utils'; import styles from './HomeView.module.css'; interface HomeViewProps { initialSchools: SchoolsResponse; filters: Filters; totalSchools?: number | null; } function daysUntil(month: number, day: number): number { const today = new Date(); today.setHours(0, 0, 0, 0); const y = today.getFullYear(); let target = new Date(y, month - 1, day); if (target <= today) target = new Date(y + 1, month - 1, day); return Math.round((target.getTime() - today.getTime()) / 86_400_000); } function formatCountdownDate(month: number, day: number): string { const today = new Date(); today.setHours(0, 0, 0, 0); const y = today.getFullYear(); let target = new Date(y, month - 1, day); if (target <= today) target = new Date(y + 1, month - 1, day); return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' }); } interface CountdownChipData { type: 'deadline' | 'offer'; track: string; milestone: string; month: number; day: number; } const ADMISSIONS_CHIPS: CountdownChipData[] = [ { type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 }, { type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 }, { type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 }, { type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 }, ]; export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const { addSchool, removeSchool, selectedSchools } = useComparisonContext(); const [resultsView, setResultsView] = useState<'list' | 'map'>('list'); const [selectedMapSchool, setSelectedMapSchool] = useState(null); const sortOrder = searchParams.get('sort') || 'default'; const [allSchools, setAllSchools] = useState(initialSchools.schools); const [currentPage, setCurrentPage] = useState(initialSchools.page); const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1); const [isLoadingMore, setIsLoadingMore] = useState(false); const [laAverages, setLaAverages] = useState>({}); const [nationalAvgRwm, setNationalAvgRwm] = useState(null); const [mapSchools, setMapSchools] = useState([]); const [isLoadingMap, setIsLoadingMap] = useState(false); const prevSearchParamsRef = useRef(searchParams.toString()); const mapParamsRef = useRef(''); const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle'); const [geoError, setGeoError] = useState(null); const [chipDays, setChipDays] = useState<(number | null)[]>(ADMISSIONS_CHIPS.map(() => null)); const hasSearch = searchParams.get('search') || searchParams.get('postcode'); const isLocationSearch = !!searchParams.get('postcode'); const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type')); const currentPhase = searchParams.get('phase') || ''; const secondaryCount = allSchools.filter(s => s.attainment_8_score != null).length; const primaryCount = allSchools.filter(s => s.rwm_expected_pct != null).length; const isSecondaryView = currentPhase.toLowerCase().includes('secondary') || (!currentPhase && secondaryCount > primaryCount); const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase; // Reset pagination and map cache when search params change useEffect(() => { const newParamsStr = searchParams.toString(); if (newParamsStr !== prevSearchParamsRef.current) { prevSearchParamsRef.current = newParamsStr; mapParamsRef.current = ''; // allow map to re-fetch for new search setAllSchools(initialSchools.schools); setCurrentPage(initialSchools.page); setHasMore(initialSchools.total_pages > 1); setMapSchools([]); } }, [searchParams, initialSchools]); // Close bottom sheet if we change views or search useEffect(() => { setSelectedMapSchool(null); }, [resultsView, searchParams]); // Fetch all schools within radius when map view is active. // Guard with a ref so toggling back to map never re-fetches the same params. useEffect(() => { if (resultsView !== 'map' || !isLocationSearch) return; const paramsKey = searchParams.toString(); if (paramsKey === mapParamsRef.current) return; mapParamsRef.current = paramsKey; setIsLoadingMap(true); const params: Record = {}; searchParams.forEach((value, key) => { params[key] = value; }); params.page = 1; params.page_size = 500; fetchSchools(params, { cache: 'no-store' }) .then(r => setMapSchools(r.schools)) .catch(() => setMapSchools(initialSchools.schools)) .finally(() => setIsLoadingMap(false)); }, [resultsView, searchParams]); // Fetch LA averages when secondary or mixed schools are visible useEffect(() => { if (!isSecondaryView && !isMixedView) return; fetchLAaverages({ cache: 'force-cache' }) .then(data => setLaAverages(data.secondary.attainment_8_by_la)) .catch(() => {}); }, [isSecondaryView, isMixedView]); // Fetch national averages (supplementary — never blocks render) useEffect(() => { fetchNationalAverages() .then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null)) .catch(() => {}); }, []); // Compute admissions countdown days client-side to avoid SSR mismatch useEffect(() => { setChipDays(ADMISSIONS_CHIPS.map(c => daysUntil(c.month, c.day))); }, []); const handleLoadMore = async () => { if (isLoadingMore || !hasMore) return; setIsLoadingMore(true); try { const params: Record = {}; searchParams.forEach((value, key) => { params[key] = value; }); params.page = currentPage + 1; params.page_size = initialSchools.page_size; const response = await fetchSchools(params, { cache: 'no-store' }); setAllSchools(prev => [...prev, ...response.schools]); setCurrentPage(response.page); setHasMore(response.page < response.total_pages); } catch { // silently ignore } finally { setIsLoadingMore(false); } }; const handleNearMe = useCallback(() => { if (!navigator.geolocation) { setGeoState('error'); setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.'); return; } setGeoState('requesting'); setGeoError(null); navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords; try { const res = await fetch( `https://api.postcodes.io/postcodes?lon=${longitude}&lat=${latitude}&limit=1` ); const data = await res.json(); if (data.result && data.result.length > 0) { const postcode = data.result[0].postcode as string; setGeoState('idle'); router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`); } else { setGeoState('error'); setGeoError('No postcode found near your location. Try entering one above.'); } } catch { setGeoState('error'); setGeoError('Could not look up your location. Please try again.'); } }, (err) => { setGeoState('error'); if (err.code === err.PERMISSION_DENIED) { setGeoError('Location access was denied. Enter a postcode above to find nearby schools.'); } else { setGeoError('Could not get your location. Please try again or enter a postcode.'); } }, { timeout: 10000, maximumAge: 60000 } ); }, [router]); const sortedSchools = [...allSchools].sort((a, b) => { if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity); if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity); if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity); if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity); if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity); if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name); return 0; }); return (
{/* Combined Hero + Search and Filters */} {!isSearchActive && (

Every English school, compared.

24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.

)} {/* Discovery section shown on landing page before any search */} {!isSearchActive && initialSchools.schools.length === 0 && (
{geoError &&

{geoError}

}
)} {/* Admissions countdown strip — only on landing page */} {!isSearchActive && (
Key admissions deadlines Full admissions guide →
{ADMISSIONS_CHIPS.map((chip, i) => { const days = chipDays[i]; const isUrgent = days !== null && days <= 14; const chipClass = [ styles.countdownChip, chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer, isUrgent ? styles.countdownChipUrgent : '', ].filter(Boolean).join(' '); const trackClass = [ styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer, ].join(' '); return (
{days ?? '—'} {days !== null && days}
{chip.milestone}
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
); })}
)} {/* How it works — only on landing page */} {!isSearchActive && (

What you'll see on every school

Primary or secondary — the page adapts to the phase
{/* Card 1 — Performance */}
{/* Primary: mini cascade */}
Primary · Year 6 · Key Stage 2 SATs
{[ { subj: 'Reading', exp: 96, exc: 73, nat: 75 }, { subj: 'Writing', exp: 81, exc: 15, nat: 72 }, { subj: 'Maths', exp: 85, exc: 47, nat: 74 }, ].map(({ subj, exp, exc, nat }) => (
{subj}
Expected{exp}%
{nat}%
Exceeding{exc}%
))}
{/* Secondary: Attainment 8 */}
Secondary · Year 11 · GCSE Attainment 8
This schoolNational avg 50.2
62.4
+12.2 vs national
Performance
Results against the national average

For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.

{/* Card 2 — Ofsted */}
Latest Ofsted inspection
OUTSTANDING
Rated Outstanding at last inspection.
Full inspection · March 2024
Judgement
Ofsted at a glance

Current grade, inspection date, and a plain-English headline — without opening a 40-page report.

{/* Card 3 — Compare */}
Metric
Our Lady
Queen of Heaven
St Mary's
Catholic Primary
{[ { label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true }, { label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true }, { label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true }, ].map(({ label, a, b, aHi }) => (
{label} {a} {b}
))}
+ pin up to 5 schools
Compare
Side-by-side shortlists

Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.

)} {/* Editorial — only on landing page */} {!isSearchActive && (
About school data

Making UK school performance data actually readable

School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.

SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.

Coverage at a glance

Schools covered {totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}
Local authorities {filters.local_authorities.length > 0 ? filters.local_authorities.length : 152}
Phases Primary & Secondary
Latest results year 2024/25
Historical data 2016–2025
Metrics per school 40+
)} {/* Results Section */}
{!hasSearch && initialSchools.schools.length > 0 && (

Featured Schools

Explore schools from across England

)} {hasSearch && (

{isLocationSearch && initialSchools.location_info ? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}` : `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found` }

{isLocationSearch && initialSchools.schools.length > 0 && (
)} {resultsView === 'list' && ( )}
)} {isSearchActive && (
{searchParams.get('search') && Search: {searchParams.get('search')} { e.preventDefault(); }}>×} {searchParams.get('local_authority') && {searchParams.get('local_authority')}} {searchParams.get('school_type') && {searchParams.get('school_type')}}
)} {initialSchools.schools.length === 0 && isSearchActive ? ( { window.location.href = '/'; }, }} /> ) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? ( /* Map View Layout */
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
s.urn === school.urn)} nationalAvgRwm={nationalAvgRwm} />
))}
{/* Mobile Bottom Sheet for Selected Map Pin */} {selectedMapSchool && (
s.urn === selectedMapSchool.urn)} nationalAvgRwm={nationalAvgRwm} />
)}
) : ( /* List View Layout */ <>
{sortedSchools.map((school) => ( school.attainment_8_score != null ? ( s.urn === school.urn)} laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null} /> ) : ( s.urn === school.urn)} nationalAvgRwm={nationalAvgRwm} /> ) ))}
{(hasMore || allSchools.length < initialSchools.total) && (

Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools

{hasMore && ( )}
)} )}
); } /* Compact School Item for Map View */ interface CompactSchoolItemProps { school: School; onAddToCompare: (school: School) => void; isInCompare: boolean; nationalAvgRwm?: number | null; } function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) { const ofstedBadge = buildOfstedListBadge(school); const isSecondary = school.attainment_8_score != null; // vs-national delta for primary schools const rwmDelta = !isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null ? Math.round(school.rwm_expected_pct - nationalAvgRwm) : null; const deltaStyle: React.CSSProperties = rwmDelta == null ? {} : rwmDelta >= 2 ? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 } : rwmDelta <= -2 ? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 } : { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' }; return (
{school.school_name} {school.distance !== undefined && school.distance !== null && ( {school.distance.toFixed(1)} mi )}
{/* Ofsted badge */}
{ofstedBadge.label}
{/* Headline metric + delta */}
{isSecondary ? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-') : (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')} {' '} {isSecondary ? 'Att 8' : 'RWM'} {rwmDelta != null && ( {rwmDelta >= 2 ? `+${rwmDelta} pts vs national` : rwmDelta <= -2 ? `${rwmDelta} pts vs national` : '≈ national avg'} )}
View
); }