Files
school_compare/nextjs-app/components/ComparisonView.tsx
T

392 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, ComparisonData> | null;
initialUrns: number[];
metrics: MetricDefinition[];
selectedMetric: string;
}
export function ComparisonView({
initialData,
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<NationalAverages | undefined>();
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>();
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<typeof info> => 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
// 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);
if (urns) {
params.set('urns', urns);
} else {
params.delete('urns');
}
params.set('metric', selectedMetric);
const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false });
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 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]);
// 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 (
<div className={styles.container}>
<header className={styles.header}>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
Add schools to your comparison basket to see them side by side inspection results,
academics, admissions and community.
</p>
</header>
<EmptyState
title="No schools selected"
message="Add schools from the home page or search to start comparing."
action={{
label: '+ Add Schools to Compare',
onClick: () => setIsModalOpen(true),
}}
/>
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
}
// Build filtered comparison data for the active phase
const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) {
activeSchools.forEach((s) => {
if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn];
}
});
}
const hasData = Object.keys(activeComparisonData).length > 0;
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerContent}>
<div>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
{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>
</div>
<div className={styles.headerActions}>
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
+ Add School
</button>
<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>
{shareConfirm ? 'Copied!' : 'Share'}
</button>
</div>
</div>
</header>
{/* Phase Tabs */}
{secondarySchools.length > 0 && primarySchools.length > 0 && (
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
)}
{activeSchools.length === 0 ? (
<EmptyState
title={`No ${comparePhase} schools in your comparison`}
message={`Add ${comparePhase} schools from search results to compare them here.`}
action={{
label: '+ Add Schools',
onClick: () => setIsModalOpen(true),
}}
/>
) : (
<>
{/* Sticky school bar — column identity while scrolling */}
<div className={styles.schoolBar} aria-label="Schools in this comparison">
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolChip}
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<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>
{hasData && (
<>
<CompareAtAGlance
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
/>
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
/>
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
benchmarks={benchmarks}
/>
<TrendsExplorer
schools={activeSchools}
data={activeComparisonData}
metrics={metrics}
metric={selectedMetric}
onMetricChange={setSelectedMetric}
isPrimaryPhase={isPrimary}
nationalAverages={nationalAverages}
/>
<p className={styles.footnote}>
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 &quot;no data&quot;.
</p>
</>
)}
</>
)}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
}