The seed effect only adopted the URL's schools when localStorage was empty, so a recipient who had ever used compare silently saw their own old shortlist instead of the shared one. Explicit URL urns now replace the stored selection on load (then persist as usual); bare /compare still restores the visitor's own selection. Adds replaceSchools() to the comparison context. Card values also switch to CHART_TEXT_COLORS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
500 lines
20 KiB
TypeScript
500 lines
20 KiB
TypeScript
/**
|
||
* ComparisonView Component
|
||
* Client-side comparison interface with phase tabs, charts, and tables
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||
import dynamic from 'next/dynamic';
|
||
import { useComparison } from '@/hooks/useComparison';
|
||
|
||
const ComparisonChart = dynamic(
|
||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
||
{ ssr: false },
|
||
);
|
||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||
import { EmptyState } from './EmptyState';
|
||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
||
import { fetchComparison } from '@/lib/api';
|
||
import { track } from '@/lib/analytics';
|
||
import styles from './ComparisonView.module.css';
|
||
|
||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||
const SECONDARY_CATEGORIES = ['gcse'];
|
||
|
||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||
{ label: 'Expected Standard', category: 'expected' },
|
||
{ label: 'Higher Standard', category: 'higher' },
|
||
{ label: 'Progress Scores', category: 'progress' },
|
||
{ label: 'Average Scores', category: 'average' },
|
||
{ label: 'Gender Performance', category: 'gender' },
|
||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||
{ label: 'School Context', category: 'context' },
|
||
{ label: 'Absence', category: 'absence' },
|
||
{ label: '3-Year Trends', category: 'trends' },
|
||
];
|
||
|
||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||
{ label: 'GCSE Performance', category: 'gcse' },
|
||
];
|
||
|
||
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, addSchool, replaceSchools, isInitialized } = useComparison();
|
||
|
||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [comparisonData, setComparisonData] = useState(initialData);
|
||
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
|
||
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 });
|
||
|
||
// Fetch comparison data
|
||
if (selectedSchools.length > 0) {
|
||
fetchComparison(urns, { cache: 'no-store' })
|
||
.then((data) => {
|
||
setComparisonData(data.comparison);
|
||
})
|
||
.catch((err) => {
|
||
console.error('Failed to fetch comparison:', err);
|
||
setComparisonData(null);
|
||
});
|
||
} else {
|
||
setComparisonData(null);
|
||
}
|
||
}, [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: any) => 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 detected phase.
|
||
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
||
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
||
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
||
useEffect(() => {
|
||
if (!comparisonData || selectedSchools.length === 0) return;
|
||
if (phaseLockedByUser.current) return;
|
||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||
setComparePhase(newPhase);
|
||
// Only reset the metric when it doesn't belong to the newly detected phase.
|
||
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
||
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);
|
||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||
setSelectedMetric(defaultMetric);
|
||
};
|
||
|
||
// compare_viewed: fire once after the page has its first selection.
|
||
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
||
// sent only when there's actual content to view, not for empty arrivals.
|
||
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 handleMetricChange = (metric: string) => {
|
||
track('compare_metric_changed', { metric, phase: comparePhase });
|
||
setSelectedMetric(metric);
|
||
};
|
||
|
||
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,
|
||
};
|
||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
||
// canShare is feature-detected because Safari iOS exposes share() but
|
||
// some configurations refuse the payload.
|
||
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) {
|
||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||
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 allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||
|
||
// Get metric definition
|
||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||
|
||
// No schools selected
|
||
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 side-by-side performance data
|
||
</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 active phase
|
||
const activeComparisonData: Record<string, ComparisonData> = {};
|
||
if (comparisonData) {
|
||
activeSchools.forEach(s => {
|
||
if (comparisonData[s.urn]) {
|
||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||
}
|
||
});
|
||
}
|
||
|
||
// Get years for table
|
||
const years =
|
||
Object.keys(activeComparisonData).length > 0
|
||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||
: [];
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* Header */}
|
||
<header className={styles.header}>
|
||
<div className={styles.headerContent}>
|
||
<div>
|
||
<h1>Compare Schools</h1>
|
||
<p className={styles.subtitle}>
|
||
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<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 */}
|
||
<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),
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* Metric Selector */}
|
||
<section className={styles.metricSelector}>
|
||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||
Select Metric:
|
||
</label>
|
||
<select
|
||
id="metric-select"
|
||
value={selectedMetric}
|
||
onChange={(e) => handleMetricChange(e.target.value)}
|
||
className={styles.metricSelect}
|
||
>
|
||
{optgroups.map(({ label, category }) => {
|
||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||
if (groupMetrics.length === 0) return null;
|
||
return (
|
||
<optgroup key={category} label={label}>
|
||
{groupMetrics.map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
);
|
||
})}
|
||
</select>
|
||
{currentMetricDef?.description && (
|
||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||
)}
|
||
</section>
|
||
|
||
{/* Progress score explanation */}
|
||
{selectedMetric.includes('progress') && (
|
||
<p className={styles.progressNote}>
|
||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||
</p>
|
||
)}
|
||
|
||
{/* School Cards */}
|
||
<section className={styles.schoolsSection}>
|
||
<div className={styles.schoolsGrid}>
|
||
{activeSchools.map((school, index) => (
|
||
<div
|
||
key={school.urn}
|
||
className={styles.schoolCard}
|
||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
||
>
|
||
<button
|
||
onClick={() => handleRemoveSchool(school.urn)}
|
||
className={styles.removeButton}
|
||
aria-label={`Remove ${school.school_name}`}
|
||
title="Remove from comparison"
|
||
>
|
||
×
|
||
</button>
|
||
<h2 className={styles.schoolName}>
|
||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||
</h2>
|
||
<div className={styles.schoolMeta}>
|
||
{school.local_authority && (
|
||
<span className={styles.metaItem}>{school.local_authority}</span>
|
||
)}
|
||
{school.school_type && (
|
||
<span className={styles.metaItem}>{school.school_type}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Latest metric value */}
|
||
{activeComparisonData[school.urn] && (
|
||
<div className={styles.latestValue}>
|
||
<div className={styles.latestLabel}>{metricLabel}</div>
|
||
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
|
||
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
width: '10px',
|
||
height: '10px',
|
||
borderRadius: '50%',
|
||
background: CHART_COLORS[index % CHART_COLORS.length],
|
||
marginRight: '0.4rem',
|
||
verticalAlign: 'middle',
|
||
}}
|
||
/>
|
||
{(() => {
|
||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||
if (yearlyData.length === 0) return '-';
|
||
|
||
const latestData = yearlyData[yearlyData.length - 1];
|
||
const value = latestData[selectedMetric as keyof typeof latestData];
|
||
|
||
if (value === null || value === undefined) return '-';
|
||
|
||
if (selectedMetric.includes('progress')) {
|
||
return formatProgress(value as number);
|
||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||
return formatPercentage(value as number);
|
||
} else {
|
||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Comparison Chart */}
|
||
{Object.keys(activeComparisonData).length > 0 ? (
|
||
<section className={styles.chartSection}>
|
||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||
<div className={styles.chartContainer}>
|
||
<ComparisonChart
|
||
comparisonData={activeComparisonData}
|
||
metric={selectedMetric}
|
||
metricLabel={metricLabel}
|
||
/>
|
||
</div>
|
||
</section>
|
||
) : activeSchools.length > 0 ? (
|
||
<section className={styles.chartSection}>
|
||
<LoadingSkeleton type="list" />
|
||
</section>
|
||
) : null}
|
||
|
||
{/* Comparison Table */}
|
||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||
<section className={styles.tableSection}>
|
||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||
<div className={styles.tableWrapper}>
|
||
<table className={styles.comparisonTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Year</th>
|
||
{activeSchools.map((school) => (
|
||
<th key={school.urn}>{school.school_name}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{years.map((year) => (
|
||
<tr key={year}>
|
||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||
{activeSchools.map((school) => {
|
||
const schoolData = activeComparisonData[school.urn];
|
||
if (!schoolData) return <td key={school.urn}>-</td>;
|
||
|
||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||
if (!yearData) return <td key={school.urn}>-</td>;
|
||
|
||
const value = yearData[selectedMetric as keyof typeof yearData];
|
||
|
||
if (value === null || value === undefined) {
|
||
return <td key={school.urn}>-</td>;
|
||
}
|
||
|
||
let displayValue: string;
|
||
if (selectedMetric.includes('progress')) {
|
||
displayValue = formatProgress(value as number);
|
||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||
displayValue = formatPercentage(value as number);
|
||
} else {
|
||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
||
}
|
||
|
||
return <td key={school.urn}>{displayValue}</td>;
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* School Search Modal */}
|
||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|