2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* ComparisonView Component
|
|
|
|
|
|
* Client-side comparison interface with charts and tables
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
|
|
|
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
|
|
|
|
import { ComparisonChart } from './ComparisonChart';
|
|
|
|
|
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
|
|
|
|
|
import { EmptyState } from './EmptyState';
|
|
|
|
|
|
import type { ComparisonData, MetricDefinition } from '@/lib/types';
|
|
|
|
|
|
import { formatPercentage, formatProgress } from '@/lib/utils';
|
2026-02-03 10:27:45 +00:00
|
|
|
|
import { fetchComparison } from '@/lib/api';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
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 } = useComparison();
|
|
|
|
|
|
|
|
|
|
|
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
|
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
|
const [comparisonData, setComparisonData] = useState(initialData);
|
|
|
|
|
|
|
|
|
|
|
|
// 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-02 22:47:37 +00:00
|
|
|
|
console.log('Fetching comparison data for URNs:', urns);
|
2026-02-03 10:27:45 +00:00
|
|
|
|
fetchComparison(urns, { cache: 'no-store' })
|
2026-02-02 22:47:37 +00:00
|
|
|
|
.then((data) => {
|
|
|
|
|
|
console.log('Comparison data received:', 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]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleMetricChange = (metric: string) => {
|
|
|
|
|
|
setSelectedMetric(metric);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemoveSchool = (urn: number) => {
|
|
|
|
|
|
removeSchool(urn);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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: 'Browse Schools',
|
|
|
|
|
|
onClick: () => router.push('/'),
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get years for table
|
|
|
|
|
|
const years =
|
|
|
|
|
|
comparisonData && Object.keys(comparisonData).length > 0
|
|
|
|
|
|
? comparisonData[Object.keys(comparisonData)[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>
|
|
|
|
|
|
<button onClick={() => setIsModalOpen(true)} className={styles.addButton}>
|
|
|
|
|
|
+ Add School
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}
|
|
|
|
|
|
>
|
|
|
|
|
|
{metrics.map((metric) => (
|
|
|
|
|
|
<option key={metric.key} value={metric.key}>
|
|
|
|
|
|
{metric.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* School Cards */}
|
|
|
|
|
|
<section className={styles.schoolsSection}>
|
|
|
|
|
|
<div className={styles.schoolsGrid}>
|
|
|
|
|
|
{selectedSchools.map((school) => (
|
|
|
|
|
|
<div key={school.urn} className={styles.schoolCard}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleRemoveSchool(school.urn)}
|
|
|
|
|
|
className={styles.removeButton}
|
|
|
|
|
|
aria-label="Remove school"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<h3 className={styles.schoolName}>
|
|
|
|
|
|
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className={styles.schoolMeta}>
|
|
|
|
|
|
{school.local_authority && (
|
2026-02-02 22:34:14 +00:00
|
|
|
|
<span className={styles.metaItem}>{school.local_authority}</span>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{school.school_type && (
|
2026-02-02 22:34:14 +00:00
|
|
|
|
<span className={styles.metaItem}>{school.school_type}</span>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Latest metric value */}
|
|
|
|
|
|
{comparisonData && comparisonData[school.urn] && (
|
|
|
|
|
|
<div className={styles.latestValue}>
|
|
|
|
|
|
<div className={styles.latestLabel}>{metricLabel}</div>
|
|
|
|
|
|
<div className={styles.latestNumber}>
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const yearlyData = comparisonData[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 '-';
|
|
|
|
|
|
|
|
|
|
|
|
// Format based on metric type
|
|
|
|
|
|
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 */}
|
2026-02-02 22:47:37 +00:00
|
|
|
|
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
2026-02-02 20:34:35 +00:00
|
|
|
|
<section className={styles.chartSection}>
|
|
|
|
|
|
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
|
|
|
|
|
<div className={styles.chartContainer}>
|
|
|
|
|
|
<ComparisonChart
|
|
|
|
|
|
comparisonData={comparisonData}
|
|
|
|
|
|
metric={selectedMetric}
|
|
|
|
|
|
metricLabel={metricLabel}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-02-02 22:47:37 +00:00
|
|
|
|
) : selectedSchools.length > 0 ? (
|
|
|
|
|
|
<section className={styles.chartSection}>
|
|
|
|
|
|
<div className={styles.loadingMessage}>Loading comparison data...</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Comparison Table */}
|
|
|
|
|
|
{comparisonData && Object.keys(comparisonData).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>
|
|
|
|
|
|
{selectedSchools.map((school) => (
|
|
|
|
|
|
<th key={school.urn}>{school.school_name}</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{years.map((year) => (
|
|
|
|
|
|
<tr key={year}>
|
|
|
|
|
|
<td className={styles.yearCell}>{year}</td>
|
|
|
|
|
|
{selectedSchools.map((school) => {
|
|
|
|
|
|
const schoolData = comparisonData[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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Format based on metric type
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|