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 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
||
* 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,
|
||
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>
|
||
);
|
||
}
|