All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 35s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Add shared button system (.btn-primary/secondary/tertiary/active) in globals.css
- Replace 40+ hardcoded rgba() values with design tokens across all CSS modules
- Add skip link, :focus-visible indicators, and ARIA landmarks
- Standardise button labels ("+ Compare" / "✓ Comparing") across all components
- Add collapse/minimize toggle to ComparisonToast
- Fix heading hierarchy (h3→h2 in ComparisonView)
- Add aria-live on search results, aria-label on trend SVGs
- Add "Search" nav link, fix footer empty section, unify max-widths
- Darken --text-muted for WCAG AA compliance (4.6:1 contrast ratio)
- Net reduction of ~180 lines through button style deduplication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
/**
|
||
* 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 { LoadingSkeleton } from './LoadingSkeleton';
|
||
import type { ComparisonData, MetricDefinition } from '@/lib/types';
|
||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
||
import { fetchComparison } from '@/lib/api';
|
||
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, addSchool, isInitialized } = useComparison();
|
||
|
||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [comparisonData, setComparisonData] = useState(initialData);
|
||
const [shareConfirm, setShareConfirm] = useState(false);
|
||
|
||
// 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
|
||
|
||
// 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]);
|
||
|
||
const handleMetricChange = (metric: string) => {
|
||
setSelectedMetric(metric);
|
||
};
|
||
|
||
const handleRemoveSchool = (urn: number) => {
|
||
removeSchool(urn);
|
||
};
|
||
|
||
const handleShare = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(window.location.href);
|
||
setShareConfirm(true);
|
||
setTimeout(() => setShareConfirm(false), 2000);
|
||
} catch { /* fallback: do nothing */ }
|
||
};
|
||
|
||
// 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>
|
||
);
|
||
}
|
||
|
||
// 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>
|
||
<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>
|
||
|
||
{/* 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}
|
||
>
|
||
<optgroup label="Expected Standard">
|
||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Higher Standard">
|
||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Progress Scores">
|
||
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Average Scores">
|
||
{metrics.filter(m => m.category === 'average').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Gender Performance">
|
||
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Equity (Disadvantaged)">
|
||
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="School Context">
|
||
{metrics.filter(m => m.category === 'context').map((metric) => (
|
||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="3-Year Trends">
|
||
{metrics.filter(m => m.category === '3yr').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}>
|
||
{selectedSchools.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={`/school/${school.urn}`}>{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 */}
|
||
{comparisonData && comparisonData[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 = 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 */}
|
||
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
||
<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>
|
||
) : selectedSchools.length > 0 ? (
|
||
<section className={styles.chartSection}>
|
||
<LoadingSkeleton type="list" />
|
||
</section>
|
||
) : null}
|
||
|
||
{/* 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>
|
||
);
|
||
}
|