PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m57s
The grid sections (At a glance, Ofsted, Getting a place, Who goes there) collapsed generically on mobile — grey label pills, full names wrapping to 3 lines, no dots — making the page ~2x the mockup's height and 'significantly different' from the mobile design. Each measure is now wrapped in a <Measure> that is display:contents on desktop (so the label + cells still flow into the shared aligned grid, unchanged) and a white card on mobile with compact [dot][short name] [value] rows — matching the mobile mockup. The sticky school bar becomes scrollable short-name pills on mobile too. Adds a shortName() util. Desktop layout is unchanged (display:contents dissolves the wrapper). Validated the card mechanism and real content shapes (report-card cell, badges, %+chip rows) via static previews at both widths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
418 lines
16 KiB
TypeScript
418 lines
16 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 } 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);
|
||
}
|
||
}
|
||
}, [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 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;
|
||
|
||
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);
|
||
});
|
||
}, [urnKey, isInitialized]);
|
||
|
||
// 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'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)}>
|
||
<span className={styles.chipNameFull}>{school.school_name}</span>
|
||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||
</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 "no data".
|
||
</p>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|