Files
school_compare/nextjs-app/components/RankingsView.tsx
TudorandClaude Opus 5.5 0a4c051ee5
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
feat(compare): limit the basket to five per phase, not five overall
A parent choosing a primary and a secondary school at once hit the old
cap of five total. The basket now holds up to five primary and five
secondary schools (ten in all), matching the compare page's phase tabs.

Schools that could land in either tab (all-through, special schools with
phase "Not applicable", unknown phase) count against both groups, so no
tab ever exceeds the five-slot chart palette and point styles.

- lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits
- search modal disables only the full group and says which one
- rankings rows carry the phase of the tab they are ranked under
- shared ?urns= links are trimmed per group
- copy: compare metadata, homepage value prop, How it works card now
  name primary and secondary schools (also better for search intent)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:56:54 +01:00

322 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RankingsView Component
* Client-side rankings interface with phase tabs and filters
*/
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
import { track } from '@/lib/analytics';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.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 RankingsViewProps {
rankings: RankingEntry[];
filters: Filters;
metrics: MetricDefinition[];
selectedMetric: string;
selectedArea?: string;
selectedYear?: number;
selectedPhase?: string;
}
export function RankingsView({
rankings,
filters,
metrics,
selectedMetric,
selectedArea,
selectedYear,
selectedPhase = 'primary',
}: RankingsViewProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { addSchool, isSelected } = useComparison();
const isPrimary = selectedPhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
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()}`);
};
const handlePhaseChange = (phase: string) => {
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
updateFilters({ phase, metric: defaultMetric });
};
const handleMetricChange = (metric: string) => {
track('metric_compared_in_rankings', { metric, phase: selectedPhase });
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,
// Ranking rows carry no phase, but the tab they're ranked under is
// theirs. The compare basket's per-phase limit needs it.
phase: isPrimary ? 'Primary' : 'Secondary',
address: null,
postcode: null,
latitude: null,
longitude: null,
} as any);
track('compare_school_added', { urn: ranking.urn, from: 'rankings' });
};
// 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');
// Filter metrics to only show relevant categories
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
return (
<div className={styles.container}>
{/* Header */}
<header className={styles.header}>
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Schools ranked by {metricLabel.toLowerCase()}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> (showing top {rankings.length})</span>}
</p>
</header>
{/* 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>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
{isProgressScore && (
<p className={styles.progressHint}>
Progress scores: 0 = national average. Positive = above average.
{isPrimary && ' KS2 progress isn’t published for 2023/24 or 2024/25 because there’s no key stage 1 baseline. Pick an earlier year to rank by it.'}
</p>
)}
{/* 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}
>
{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>
</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}
>
<option value="">
{filters.years.length > 0 ? `${formatAcademicYear(Math.max(...filters.years))} (Latest)` : 'Latest'}
</option>
{filters.years.map((year) => (
<option key={year} value={year}>
{formatAcademicYear(year)}
</option>
))}
</select>
</div>
</section>
{/* Rankings Table */}
<section className={styles.rankingsSection}>
{rankings.length === 0 ? (
<EmptyState
title="No rankings found"
message={
isPrimary && isProgressScore
? 'KS2 progress scores aren’t published for the most recent years (2023/24 and 2024/25) because there’s no key stage 1 baseline. Select an earlier year to see progress rankings.'
: 'Try selecting a different metric, area, or year.'
}
action={{
label: 'Clear filters',
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
}}
/>
) : (
<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}>
{/* Inner block caps the column's measured width so long
labels wrap instead of widening the table off-screen. */}
<span className={styles.valueHeaderText}>{metricLabel}</span>
</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}>
{isTopThree ? (
<span className={`${styles.rankBadge} ${styles[`rankBadge${rank}`]}`}>
{rank}
</span>
) : (
<span className={styles.rankNumber}>{rank}</span>
)}
</td>
<td className={styles.schoolCell}>
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
{ranking.school_name}
</a>
{/* On phones the Area column is hidden; the LA moves here
so the metric value fits on screen without swiping. */}
{ranking.local_authority && (
<span className={styles.schoolCellArea}>{ranking.local_authority}</span>
)}
</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}>
<a href={schoolUrl(ranking.urn, ranking.school_name)} className="btn btn-tertiary btn-sm">View</a>
<button
onClick={() => handleAddToCompare(ranking)}
disabled={alreadyInComparison}
className={alreadyInComparison ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
>
{alreadyInComparison ? '✓ Comparing' : '+ Compare'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}