Files
school_compare/nextjs-app/components/RankingsView.tsx
T
TudorandClaude Opus 4.8 a5b2296240
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
fix(rankings,admissions): correct accuracy issues from education audit
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
2026-07-21 12:08:50 +01:00

319 lines
12 KiB
TypeScript
Raw 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,
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 isnt published for 2023/24 or 2024/25 (theres 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 arent published for the most recent years (2023/24 and 2024/25) because theres 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>
);
}