PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
474 lines
19 KiB
TypeScript
474 lines
19 KiB
TypeScript
/**
|
||
* 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<string, ComparisonData> | 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<NationalAverages | undefined>(
|
||
initialNationalAverages,
|
||
);
|
||
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(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<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);
|
||
}
|
||
}
|
||
// 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 (
|
||
<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'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. On desktop
|
||
it shares the sections' grid template (via --school-count) so
|
||
each chip sits exactly over the column it labels. */}
|
||
<div
|
||
className={styles.schoolBar}
|
||
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||
aria-label="Schools in this comparison"
|
||
>
|
||
{/* Fills the 200px label rail on desktop (hidden on mobile).
|
||
All-through schools must not be miscounted as "primary
|
||
schools"/"secondary schools" — mixed baskets get "· primary
|
||
view" phrasing instead. */}
|
||
<div className={styles.barCaption}>
|
||
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||
<span className={styles.barCaptionCount}>
|
||
{activeSchools.every((sch) =>
|
||
sch.phase?.toLowerCase().includes(comparePhase),
|
||
)
|
||
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
|
||
: `${activeSchools.length} schools · ${comparePhase} view`}
|
||
</span>
|
||
</div>
|
||
{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)}>
|
||
<span className={styles.chipNameFull}>{school.school_name}</span>
|
||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||
</a>
|
||
<span className={styles.chipMeta}>
|
||
{[
|
||
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
|
||
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}
|
||
isSecondary={!isPrimary}
|
||
/>
|
||
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||
<CompareAcademics
|
||
schools={activeSchools}
|
||
data={activeComparisonData}
|
||
nationalAverages={nationalAverages}
|
||
benchmarks={benchmarks}
|
||
isSecondary={!isPrimary}
|
||
/>
|
||
<CompareAdmissions
|
||
schools={activeSchools}
|
||
data={activeComparisonData}
|
||
isSecondary={!isPrimary}
|
||
/>
|
||
<CompareCommunity
|
||
schools={activeSchools}
|
||
data={activeComparisonData}
|
||
benchmarks={benchmarks}
|
||
isSecondary={!isPrimary}
|
||
/>
|
||
<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 "no data".
|
||
</p>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|