PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m11s
Follow-up audit of the rankings and admissions pages. Admissions (AdmissionsView.tsx): - National Offer Day no longer claims offers publish "from 12:01 am"; release times are set per-council (often late afternoon, some overnight), so it now tells parents to check their council's page. - Secondary preference count no longer states a flat "up to six" (that's London/Pan-London); most LAs allow three to six. Mirrors the hedge the primary step already used. - Added the two deadlines that most often catch parents out: selective schools' separate entrance-test registration (months earlier), and faith schools' supplementary information form sent direct to the school. Covered in both the primary and secondary criteria steps. - Reworded the equal-preference tip so it's precise: order is the tie-break among schools you qualify for (you get the highest-ranked one), not irrelevant. Rankings (RankingsView.tsx): - Subtitle "Top-performing schools by X" -> "Schools ranked by X", so it isn't nonsensical for context/equity/absence metrics. - KS2 progress isn't published for 2023/24 or 2024/25 (no KS1 baseline). Selecting a primary progress metric on a recent year used to dead-end on a generic "No rankings found". Added a scoped caveat to the progress hint and an explanatory empty-state (primary only — secondary Progress 8 is published). Added an admissions smoke journey (static content, stable milestones). Verified with tsc --noEmit and next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
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 (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>
|
||
);
|
||
}
|