2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* RankingsView Component
|
2026-03-29 08:57:06 +01:00
|
|
|
* Client-side rankings interface with phase tabs and filters
|
2026-02-02 20:34:35 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
|
|
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
|
|
|
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
2026-04-01 14:58:13 +01:00
|
|
|
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
2026-03-23 21:31:28 +00:00
|
|
|
import { EmptyState } from './EmptyState';
|
2026-02-02 20:34:35 +00:00
|
|
|
import styles from './RankingsView.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 RankingsViewProps {
|
|
|
|
|
rankings: RankingEntry[];
|
|
|
|
|
filters: Filters;
|
|
|
|
|
metrics: MetricDefinition[];
|
|
|
|
|
selectedMetric: string;
|
|
|
|
|
selectedArea?: string;
|
|
|
|
|
selectedYear?: number;
|
2026-03-29 08:57:06 +01:00
|
|
|
selectedPhase?: string;
|
2026-02-02 20:34:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function RankingsView({
|
|
|
|
|
rankings,
|
|
|
|
|
filters,
|
|
|
|
|
metrics,
|
|
|
|
|
selectedMetric,
|
|
|
|
|
selectedArea,
|
|
|
|
|
selectedYear,
|
2026-03-29 08:57:06 +01:00
|
|
|
selectedPhase = 'primary',
|
2026-02-02 20:34:35 +00:00
|
|
|
}: RankingsViewProps) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
const searchParams = useSearchParams();
|
|
|
|
|
const { addSchool, isSelected } = useComparison();
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
const isPrimary = selectedPhase === 'primary';
|
|
|
|
|
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
|
|
|
|
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
const updateFilters = (updates: Record<string, string | undefined>) => {
|
|
|
|
|
const params = new URLSearchParams(searchParams);
|
|
|
|
|
|
|
|
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
|
|
|
if (value) {
|
|
|
|
|
params.set(key, value);
|
|
|
|
|
} else {
|
|
|
|
|
params.delete(key);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.push(`${pathname}?${params.toString()}`);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
const handlePhaseChange = (phase: string) => {
|
|
|
|
|
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
|
|
|
|
updateFilters({ phase, metric: defaultMetric });
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
const handleMetricChange = (metric: string) => {
|
|
|
|
|
updateFilters({ metric });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAreaChange = (area: string) => {
|
|
|
|
|
updateFilters({ local_authority: area || undefined });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleYearChange = (year: string) => {
|
|
|
|
|
updateFilters({ year: year || undefined });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAddToCompare = (ranking: RankingEntry) => {
|
|
|
|
|
addSchool({
|
|
|
|
|
...ranking,
|
|
|
|
|
address: null,
|
|
|
|
|
postcode: null,
|
|
|
|
|
latitude: null,
|
|
|
|
|
longitude: null,
|
|
|
|
|
} as any);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Get metric definition
|
|
|
|
|
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
|
|
|
|
const metricLabel = currentMetricDef?.label || selectedMetric;
|
|
|
|
|
const isProgressScore = selectedMetric.includes('progress');
|
|
|
|
|
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
|
|
|
|
|
2026-03-29 08:57:06 +01:00
|
|
|
// Filter metrics to only show relevant categories
|
|
|
|
|
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
return (
|
|
|
|
|
<div className={styles.container}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<header className={styles.header}>
|
|
|
|
|
<h1>School Rankings</h1>
|
|
|
|
|
<p className={styles.subtitle}>
|
|
|
|
|
Top-performing schools by {metricLabel.toLowerCase()}
|
2026-03-29 08:57:06 +01:00
|
|
|
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
2026-02-02 20:34:35 +00:00
|
|
|
</p>
|
|
|
|
|
</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')}
|
|
|
|
|
>
|
|
|
|
|
Primary (KS2)
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
|
|
|
|
onClick={() => handlePhaseChange('secondary')}
|
|
|
|
|
>
|
|
|
|
|
Secondary (GCSE)
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-23 21:31:28 +00:00
|
|
|
{currentMetricDef?.description && (
|
|
|
|
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
{isProgressScore && (
|
|
|
|
|
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
{/* Filters */}
|
|
|
|
|
<section className={styles.filters}>
|
|
|
|
|
<div className={styles.filterGroup}>
|
|
|
|
|
<label htmlFor="metric-select" className={styles.filterLabel}>
|
|
|
|
|
Metric:
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="metric-select"
|
|
|
|
|
value={selectedMetric}
|
|
|
|
|
onChange={(e) => handleMetricChange(e.target.value)}
|
|
|
|
|
className={styles.filterSelect}
|
|
|
|
|
>
|
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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-02-02 20:34:35 +00:00
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.filterGroup}>
|
|
|
|
|
<label htmlFor="area-select" className={styles.filterLabel}>
|
|
|
|
|
Area:
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="area-select"
|
|
|
|
|
value={selectedArea || ''}
|
|
|
|
|
onChange={(e) => handleAreaChange(e.target.value)}
|
|
|
|
|
className={styles.filterSelect}
|
|
|
|
|
>
|
|
|
|
|
<option value="">All Areas</option>
|
|
|
|
|
{filters.local_authorities.map((area) => (
|
|
|
|
|
<option key={area} value={area}>
|
|
|
|
|
{area}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.filterGroup}>
|
|
|
|
|
<label htmlFor="year-select" className={styles.filterLabel}>
|
|
|
|
|
Year:
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="year-select"
|
|
|
|
|
value={selectedYear?.toString() || ''}
|
|
|
|
|
onChange={(e) => handleYearChange(e.target.value)}
|
|
|
|
|
className={styles.filterSelect}
|
|
|
|
|
>
|
2026-03-23 21:31:28 +00:00
|
|
|
<option value="">
|
2026-04-01 14:58:13 +01:00
|
|
|
{filters.years.length > 0 ? `${formatAcademicYear(Math.max(...filters.years))} (Latest)` : 'Latest'}
|
2026-03-23 21:31:28 +00:00
|
|
|
</option>
|
2026-02-02 20:34:35 +00:00
|
|
|
{filters.years.map((year) => (
|
|
|
|
|
<option key={year} value={year}>
|
2026-04-01 14:58:13 +01:00
|
|
|
{formatAcademicYear(year)}
|
2026-02-02 20:34:35 +00:00
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Rankings Table */}
|
|
|
|
|
<section className={styles.rankingsSection}>
|
|
|
|
|
{rankings.length === 0 ? (
|
2026-03-23 21:31:28 +00:00
|
|
|
<EmptyState
|
|
|
|
|
title="No rankings found"
|
|
|
|
|
message="Try selecting a different metric, area, or year."
|
|
|
|
|
action={{
|
|
|
|
|
label: 'Clear filters',
|
2026-03-29 08:57:06 +01:00
|
|
|
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
2026-03-23 21:31:28 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-02 20:34:35 +00:00
|
|
|
) : (
|
|
|
|
|
<div className={styles.tableWrapper}>
|
|
|
|
|
<table className={styles.rankingsTable}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th className={styles.rankHeader}>Rank</th>
|
|
|
|
|
<th className={styles.schoolHeader}>School</th>
|
|
|
|
|
<th className={styles.areaHeader}>Area</th>
|
|
|
|
|
<th className={styles.typeHeader}>Type</th>
|
|
|
|
|
<th className={styles.valueHeader}>{metricLabel}</th>
|
|
|
|
|
<th className={styles.actionHeader}>Action</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{rankings.map((ranking, index) => {
|
|
|
|
|
const rank = index + 1;
|
|
|
|
|
const isTopThree = rank <= 3;
|
|
|
|
|
const alreadyInComparison = isSelected(ranking.urn);
|
|
|
|
|
|
|
|
|
|
// Format the value
|
|
|
|
|
let displayValue: string;
|
|
|
|
|
if (ranking.value === null || ranking.value === undefined) {
|
|
|
|
|
displayValue = '-';
|
|
|
|
|
} else if (isProgressScore) {
|
|
|
|
|
displayValue = formatProgress(ranking.value);
|
|
|
|
|
} else if (isPercentage) {
|
|
|
|
|
displayValue = formatPercentage(ranking.value);
|
|
|
|
|
} else {
|
|
|
|
|
displayValue = ranking.value.toFixed(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
key={ranking.urn}
|
|
|
|
|
className={isTopThree ? styles[`rank${rank}`] : ''}
|
|
|
|
|
>
|
|
|
|
|
<td className={styles.rankCell}>
|
2026-02-03 14:12:48 +00:00
|
|
|
{isTopThree ? (
|
|
|
|
|
<span className={`${styles.rankBadge} ${styles[`rankBadge${rank}`]}`}>
|
|
|
|
|
{rank}
|
2026-02-02 20:34:35 +00:00
|
|
|
</span>
|
2026-02-03 14:12:48 +00:00
|
|
|
) : (
|
|
|
|
|
<span className={styles.rankNumber}>{rank}</span>
|
2026-02-02 20:34:35 +00:00
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className={styles.schoolCell}>
|
2026-03-29 14:15:06 +01:00
|
|
|
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
|
2026-02-02 20:34:35 +00:00
|
|
|
{ranking.school_name}
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
|
|
|
|
|
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
|
|
|
|
|
<td className={styles.valueCell}>
|
|
|
|
|
<strong>{displayValue}</strong>
|
|
|
|
|
</td>
|
|
|
|
|
<td className={styles.actionCell}>
|
2026-03-29 14:15:06 +01:00
|
|
|
<a href={schoolUrl(ranking.urn, ranking.school_name)} className="btn btn-tertiary btn-sm">View</a>
|
2026-02-02 20:34:35 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => handleAddToCompare(ranking)}
|
|
|
|
|
disabled={alreadyInComparison}
|
2026-03-25 20:28:03 +00:00
|
|
|
className={alreadyInComparison ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
2026-02-02 20:34:35 +00:00
|
|
|
>
|
2026-03-25 20:28:03 +00:00
|
|
|
{alreadyInComparison ? '✓ Comparing' : '+ Compare'}
|
2026-02-02 20:34:35 +00:00
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|