2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* ComparisonView Component
|
2026-03-29 08:57:06 +01:00
|
|
|
|
* Client-side comparison interface with phase tabs, charts, and tables
|
2026-02-02 20:34:35 +00:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-04-16 09:11:47 +01:00
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
2026-06-02 13:46:45 +01:00
|
|
|
|
import dynamic from 'next/dynamic';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { useComparison } from '@/hooks/useComparison';
|
2026-06-02 13:46:45 +01:00
|
|
|
|
|
|
|
|
|
|
const ComparisonChart = dynamic(
|
|
|
|
|
|
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
|
|
|
|
|
{ ssr: false },
|
|
|
|
|
|
);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
|
|
|
|
|
import { EmptyState } from './EmptyState';
|
2026-03-23 21:31:28 +00:00
|
|
|
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
2026-03-29 08:57:06 +01:00
|
|
|
|
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
2026-04-01 14:58:13 +01:00
|
|
|
|
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
2026-02-03 10:27:45 +00:00
|
|
|
|
import { fetchComparison } from '@/lib/api';
|
2026-05-19 22:04:22 +01:00
|
|
|
|
import { track } from '@/lib/analytics';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import styles from './ComparisonView.module.css';
|
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
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' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
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();
|
2026-03-23 21:31:28 +00:00
|
|
|
|
const { selectedSchools, removeSchool, addSchool, isInitialized } = useComparison();
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
|
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
|
const [comparisonData, setComparisonData] = useState(initialData);
|
2026-03-23 21:31:28 +00:00
|
|
|
|
const [shareConfirm, setShareConfirm] = useState(false);
|
2026-03-29 08:57:06 +01:00
|
|
|
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
2026-04-16 09:11:47 +01:00
|
|
|
|
// 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);
|
2026-03-23 21:31:28 +00:00
|
|
|
|
|
|
|
|
|
|
// Seed context from initialData when component mounts and localStorage is empty
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isInitialized) return;
|
|
|
|
|
|
if (selectedSchools.length === 0 && initialUrns.length > 0 && initialData) {
|
|
|
|
|
|
initialUrns.forEach(urn => {
|
|
|
|
|
|
const data = initialData[String(urn)];
|
|
|
|
|
|
if (data?.school_info) {
|
|
|
|
|
|
addSchool(data.school_info);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-02-03 10:27:45 +00:00
|
|
|
|
fetchComparison(urns, { cache: 'no-store' })
|
2026-02-02 22:47:37 +00:00
|
|
|
|
.then((data) => {
|
|
|
|
|
|
setComparisonData(data.comparison);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
console.error('Failed to fetch comparison:', err);
|
|
|
|
|
|
setComparisonData(null);
|
|
|
|
|
|
});
|
2026-02-02 20:34:35 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
setComparisonData(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-04-16 08:57:02 +01:00
|
|
|
|
// 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.
|
2026-03-29 08:57:06 +01:00
|
|
|
|
useEffect(() => {
|
2026-04-16 08:57:02 +01:00
|
|
|
|
if (!comparisonData || selectedSchools.length === 0) return;
|
2026-04-16 09:11:47 +01:00
|
|
|
|
if (phaseLockedByUser.current) return;
|
2026-04-16 08:57:02 +01:00
|
|
|
|
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');
|
2026-03-29 08:57:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
2026-04-16 09:11:47 +01:00
|
|
|
|
phaseLockedByUser.current = true;
|
2026-03-29 08:57:06 +01:00
|
|
|
|
setComparePhase(phase);
|
|
|
|
|
|
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
|
|
|
|
|
setSelectedMetric(defaultMetric);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-19 22:04:22 +01:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
const handleMetricChange = (metric: string) => {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('compare_metric_changed', { metric, phase: comparePhase });
|
2026-02-02 20:34:35 +00:00
|
|
|
|
setSelectedMetric(metric);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemoveSchool = (urn: number) => {
|
|
|
|
|
|
removeSchool(urn);
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('compare_school_removed', { urn, from: 'compare' });
|
2026-02-02 20:34:35 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-23 21:31:28 +00:00
|
|
|
|
const handleShare = async () => {
|
2026-05-19 09:40:23 +01:00
|
|
|
|
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);
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('compare_shared', { method: 'native', school_count: count });
|
2026-05-19 09:40:23 +01:00
|
|
|
|
return;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// User cancelled — bail silently. Any other error falls through to clipboard.
|
|
|
|
|
|
if ((err as DOMException)?.name === 'AbortError') return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 21:31:28 +00:00
|
|
|
|
try {
|
2026-05-19 09:40:23 +01:00
|
|
|
|
await navigator.clipboard.writeText(url);
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
2026-03-23 21:31:28 +00:00
|
|
|
|
setShareConfirm(true);
|
|
|
|
|
|
setTimeout(() => setShareConfirm(false), 2000);
|
|
|
|
|
|
} catch { /* fallback: do nothing */ }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
// 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={{
|
2026-03-23 21:31:28 +00:00
|
|
|
|
label: '+ Add Schools to Compare',
|
|
|
|
|
|
onClick: () => setIsModalOpen(true),
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-03-23 21:31:28 +00:00
|
|
|
|
|
|
|
|
|
|
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
// Get years for table
|
|
|
|
|
|
const years =
|
2026-03-29 08:57:06 +01:00
|
|
|
|
Object.keys(activeComparisonData).length > 0
|
|
|
|
|
|
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
2026-02-02 20:34:35 +00:00
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-03-23 21:31:28 +00:00
|
|
|
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
2026-03-25 20:28:03 +00:00
|
|
|
|
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
2026-03-23 21:31:28 +00:00
|
|
|
|
+ Add School
|
|
|
|
|
|
</button>
|
2026-03-25 20:28:03 +00:00
|
|
|
|
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
2026-03-23 21:31:28 +00:00
|
|
|
|
<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>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
{/* Phase Tabs */}
|
|
|
|
|
|
<div className={styles.phaseTabs}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
|
|
|
|
|
onClick={() => handlePhaseChange('primary')}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
Primary ({primarySchools.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
|
|
|
|
|
onClick={() => handlePhaseChange('secondary')}
|
|
|
|
|
|
>
|
|
|
|
|
|
Secondary ({secondarySchools.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-23 21:31:28 +00:00
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
|
{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}
|
2026-03-23 21:31:28 +00:00
|
|
|
|
>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
{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}>
|
2026-03-29 12:41:28 +01:00
|
|
|
|
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
|
|
|
|
|
|
{/* Latest metric value */}
|
|
|
|
|
|
{activeComparisonData[school.urn] && (
|
|
|
|
|
|
<div className={styles.latestValue}>
|
|
|
|
|
|
<div className={styles.latestLabel}>{metricLabel}</div>
|
|
|
|
|
|
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_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>
|
|
|
|
|
|
)}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
))}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
</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}>
|
2026-04-01 14:58:13 +01:00
|
|
|
|
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
2026-03-29 08:57:06 +01:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* School Search Modal */}
|
|
|
|
|
|
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|