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-05-19 22:04:22 +01:00
import { track } from '@/lib/analytics' ;
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 ) => {
2026-07-02 21:34:07 +01:00
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct' ;
2026-03-29 08:57:06 +01:00
updateFilters ({ phase , metric : defaultMetric });
};
2026-02-02 20:34:35 +00:00
const handleMetricChange = ( metric : string ) => {
2026-05-19 22:04:22 +01:00
track ( 'metric_compared_in_rankings' , { metric , phase : selectedPhase });
2026-02-02 20:34:35 +00:00
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 );
2026-05-19 22:04:22 +01:00
track ( 'compare_school_added' , { urn : ranking.urn , from : 'rankings' });
2026-02-02 20:34:35 +00:00
};
// 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 }>
2026-07-21 12:08:50 +01:00
Schools ranked 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 && (
2026-07-21 12:08:50 +01:00
< 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 >
2026-03-23 21:31:28 +00:00
)}
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"
2026-07-21 12:08:50 +01:00
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.'
}
2026-03-23 21:31:28 +00:00
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 >
2026-07-02 21:40:30 +01:00
< 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 >
2026-02-02 20:34:35 +00:00
< 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 >
2026-07-02 21:34:07 +01:00
{ /* 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 >
)}
2026-02-02 20:34:35 +00:00
</ 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 >
);
}