2026-02-02 20:34:35 +00:00
/**
* PerformanceChart Component
2026-05-19 12:49:19 +01:00
* Displays school performance data over time using Chart.js.
*
* Desktop: full multi-series chart with dual y-axis (percentage + progress).
* Mobile (≤640px): a chip selector switches the chart between one focused view
* at a time — no dual axis, no legend, auto-scaled y-axis. Designed so the
* actual variation in the data is visible on a phone instead of a flat line.
2026-02-02 20:34:35 +00:00
*/
'use client' ;
2026-07-05 22:02:26 +01:00
import { useMemo , useState } from 'react' ;
2026-02-02 20:34:35 +00:00
import { Line } from 'react-chartjs-2' ;
2026-04-15 22:45:46 +01:00
import { ChartOptions , ChartDataset } from 'chart.js' ;
import '@/lib/chartSetup' ;
2026-02-02 20:34:35 +00:00
import type { SchoolResult } from '@/lib/types' ;
2026-04-01 14:58:13 +01:00
import { formatAcademicYear } from '@/lib/utils' ;
2026-07-20 12:19:37 +01:00
import { fillAcademicYears } from '@/lib/compareChartData' ;
2026-07-05 22:02:26 +01:00
import { useIsMobile } from '@/hooks/useIsMobile' ;
2026-05-19 22:04:22 +01:00
import { track } from '@/lib/analytics' ;
2026-02-02 20:34:35 +00:00
import styles from './PerformanceChart.module.css' ;
2026-04-09 13:55:14 +01:00
interface NationalByYear {
year : number ;
primary : Record < string , number >;
secondary : Record < string , number >;
}
2026-02-02 20:34:35 +00:00
interface PerformanceChartProps {
data : SchoolResult [];
schoolName : string ;
2026-03-28 14:59:40 +00:00
isSecondary? : boolean ;
2026-04-08 15:13:13 +01:00
nationalRwmAvg? : number | null ;
nationalAtt8Avg? : number | null ;
2026-04-09 13:55:14 +01:00
nationalByYear? : NationalByYear [];
2026-02-02 20:34:35 +00:00
}
2026-05-19 12:49:19 +01:00
// Mobile chip definitions: which datasets render when each chip is active.
// `series` keys reference the dataset labels so we can filter cleanly.
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8' ;
interface ChipDef {
id : ChipId ;
label : string ;
/** Dataset labels (from the desktop dataset list below) this chip surfaces. */
series : string [];
}
const PRIMARY_CHIPS : ChipDef [] = [
{ id : 'expected' , label : 'At expected level' , series : [ 'Reading, Writing & Maths expected %' , 'National average' ] },
{ id : 'higher' , label : 'Above expected level' , series : [ 'Exceeding expected level' ] },
{ id : 'progress' , label : 'Pupil progress' , series : [ 'Reading progress' , 'Writing progress' , 'Maths progress' ] },
];
const SECONDARY_CHIPS : ChipDef [] = [
{ id : 'attainment8' , label : 'Attainment 8' , series : [ 'Attainment 8' , 'National average' ] },
{ id : 'em_pass' , label : 'English & Maths grade 4+' , series : [ 'English & Maths Grade 4+' ] },
{ id : 'progress8' , label : 'Progress 8' , series : [ 'Progress 8' ] },
];
2026-04-08 15:13:13 +01:00
export function PerformanceChart ({
data ,
isSecondary = false ,
nationalRwmAvg ,
nationalAtt8Avg ,
2026-04-09 13:55:14 +01:00
nationalByYear ,
2026-04-08 15:13:13 +01:00
} : PerformanceChartProps ) {
2026-02-02 20:34:35 +00:00
const sortedData = [... data ]. sort (( a , b ) => a . year - b . year );
2026-07-20 12:19:37 +01:00
// Gap-honest year axis: every academic year between the first and last data
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
// KS2 — 2021/22) render as real gaps rather than compressed time. School
// lines never bridge these gaps (spanGaps:false below).
const axisYears = fillAcademicYears ( sortedData . map ( d => d . year ));
const byYear = new Map ( sortedData . map ( d => [ d . year , d ]));
const col = ( key : keyof SchoolResult ) : ( number | null )[] =>
axisYears . map ( y => {
const v = byYear . get ( y ) ? .[ key ];
return typeof v === 'number' ? v : null ;
});
const years = axisYears . map ( formatAcademicYear );
2026-02-02 20:34:35 +00:00
2026-07-05 22:02:26 +01:00
const isMobile = useIsMobile ();
2026-05-19 12:49:19 +01:00
2026-07-20 12:19:37 +01:00
// ── Build per-year national averages (aligned to the filled axis) ────
const natRefRwm : ( number | null )[] = axisYears . map ( y => {
2026-04-09 13:55:14 +01:00
if ( nationalByYear ) {
2026-07-20 12:19:37 +01:00
const match = nationalByYear . find ( n => n . year === y );
2026-04-09 13:55:14 +01:00
return match ? . primary ? . rwm_expected_pct ?? null ;
}
return nationalRwmAvg ?? null ;
});
2026-07-20 12:19:37 +01:00
const natRefAtt8 : ( number | null )[] = axisYears . map ( y => {
2026-04-09 13:55:14 +01:00
if ( nationalByYear ) {
2026-07-20 12:19:37 +01:00
const match = nationalByYear . find ( n => n . year === y );
2026-04-09 13:55:14 +01:00
return match ? . secondary ? . attainment_8_score ?? null ;
}
return nationalAtt8Avg ?? null ;
});
const hasNatRwm = natRefRwm . some ( v => v != null );
const hasNatAtt8 = natRefAtt8 . some ( v => v != null );
2026-05-19 12:49:19 +01:00
// ── Trend summary (primary only — references headline metric) ────────
2026-04-08 15:13:13 +01:00
const trendSummary = (() => {
if ( isSecondary ) return null ;
const rwm = sortedData . filter ( d => d . rwm_expected_pct != null );
if ( rwm . length < 2 ) return null ;
const latest = rwm [ rwm . length - 1 ];
const prev = rwm [ rwm . length - 2 ];
const best = rwm . reduce (( a , b ) => ( b . rwm_expected_pct ! > a . rwm_expected_pct ! ? b : a ));
const latestPct = Math . round ( latest . rwm_expected_pct ! );
const bestPct = Math . round ( best . rwm_expected_pct ! );
const delta = latest . rwm_expected_pct ! - prev . rwm_expected_pct ! ;
const arrow = delta > 1 ? '↑' : delta < - 1 ? '↓' : '→' ;
if ( best . year === latest . year ) {
2026-06-02 15:16:31 +01:00
return ` ${ arrow } Best year on record — ${ latestPct } % met the expected standard in Reading, Writing & Maths` ;
2026-04-08 15:13:13 +01:00
}
2026-06-02 15:16:31 +01:00
return ` ${ arrow } Reading, Writing & Maths peaked at ${ bestPct } % ( ${ formatAcademicYear ( best . year ) } ), currently ${ latestPct } %` ;
2026-04-08 15:13:13 +01:00
})();
2026-07-20 12:19:37 +01:00
// A gap year is any filled axis year the school has no results row for —
// exactly the cancelled/unpublished years the fill introduced. Drives the
// honest note below (KS2 and KS4 have different gap stories).
const gapYears = axisYears . filter ( y => ! byYear . has ( y ));
const hasGap = gapYears . length > 0 ;
2026-04-08 15:13:13 +01:00
2026-05-19 12:49:19 +01:00
// ── Datasets (full set; mobile filters them via the active chip) ─────
2026-04-08 15:13:13 +01:00
const refLineStyle = {
borderColor : 'rgba(90,80,70,0.35)' ,
backgroundColor : 'transparent' ,
borderWidth : 1.5 ,
borderDash : [ 6 , 4 ] as number [],
pointRadius : 0 ,
tension : 0 ,
2026-07-20 12:19:37 +01:00
spanGaps : false as const ,
2026-04-08 15:13:13 +01:00
order : 10 ,
};
2026-05-19 12:49:19 +01:00
const allDatasets : ChartDataset < 'line' > [] = isSecondary ? [
2026-02-02 20:34:35 +00:00
{
2026-03-28 14:59:40 +00:00
label : 'Attainment 8' ,
2026-07-20 12:19:37 +01:00
data : col ( 'attainment_8_score' ),
2026-04-08 15:13:13 +01:00
borderColor : '#2d7d7d' ,
backgroundColor : 'rgba(45,125,125,0.08)' ,
borderWidth : 2.5 ,
2026-03-28 14:59:40 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 4 ,
pointHoverRadius : 6 ,
2026-03-28 14:59:40 +00:00
yAxisID : 'y' ,
},
{
label : 'English & Maths Grade 4+' ,
2026-07-20 12:19:37 +01:00
data : col ( 'english_maths_standard_pass_pct' ),
2026-04-08 15:13:13 +01:00
borderColor : '#c9a227' ,
backgroundColor : 'rgba(201,162,39,0.08)' ,
borderWidth : 1.5 ,
2026-03-28 14:59:40 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
2026-03-28 14:59:40 +00:00
yAxisID : 'y' ,
},
{
label : 'Progress 8' ,
2026-07-20 12:19:37 +01:00
data : col ( 'progress_8_score' ),
2026-04-08 15:13:13 +01:00
borderColor : 'rgb(139,92,246)' ,
backgroundColor : 'rgba(139,92,246,0.08)' ,
borderWidth : 1.5 ,
2026-03-28 14:59:40 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
hidden : true ,
2026-03-28 14:59:40 +00:00
yAxisID : 'y1' ,
},
2026-04-09 13:55:14 +01:00
...( hasNatAtt8 ? [{
2026-04-08 15:13:13 +01:00
... refLineStyle ,
label : 'National average' ,
2026-04-09 13:55:14 +01:00
data : natRefAtt8 ,
2026-04-08 15:13:13 +01:00
yAxisID : 'y' ,
} as ChartDataset < 'line' > ] : []),
2026-03-28 14:59:40 +00:00
] : [
{
2026-04-08 15:13:13 +01:00
label : 'Reading, Writing & Maths expected %' ,
2026-07-20 12:19:37 +01:00
data : col ( 'rwm_expected_pct' ),
2026-04-08 15:13:13 +01:00
borderColor : '#2d7d7d' ,
backgroundColor : 'rgba(45,125,125,0.08)' ,
borderWidth : 2.5 ,
2026-02-02 20:34:35 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 4 ,
pointHoverRadius : 6 ,
yAxisID : 'y' ,
2026-02-02 20:34:35 +00:00
},
{
2026-04-08 15:13:13 +01:00
label : 'Exceeding expected level' ,
2026-07-20 12:19:37 +01:00
data : col ( 'rwm_high_pct' ),
2026-04-08 15:13:13 +01:00
borderColor : '#c9a227' ,
backgroundColor : 'rgba(201,162,39,0.08)' ,
borderWidth : 1.5 ,
2026-02-02 20:34:35 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
yAxisID : 'y' ,
2026-02-02 20:34:35 +00:00
},
2026-04-09 13:55:14 +01:00
...( hasNatRwm ? [{
2026-04-08 15:13:13 +01:00
... refLineStyle ,
label : 'National average' ,
2026-04-09 13:55:14 +01:00
data : natRefRwm ,
2026-04-08 15:13:13 +01:00
yAxisID : 'y' ,
} as ChartDataset < 'line' > ] : []),
2026-02-02 20:34:35 +00:00
{
2026-04-08 15:13:13 +01:00
label : 'Reading progress' ,
2026-07-20 12:19:37 +01:00
data : col ( 'reading_progress' ),
2026-04-08 15:13:13 +01:00
borderColor : 'rgb(59,130,246)' ,
backgroundColor : 'rgba(59,130,246,0.08)' ,
borderWidth : 1.5 ,
2026-02-02 20:34:35 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
hidden : true ,
2026-02-02 20:34:35 +00:00
yAxisID : 'y1' ,
},
{
2026-04-08 15:13:13 +01:00
label : 'Writing progress' ,
2026-07-20 12:19:37 +01:00
data : col ( 'writing_progress' ),
2026-04-08 15:13:13 +01:00
borderColor : 'rgb(139,92,246)' ,
backgroundColor : 'rgba(139,92,246,0.08)' ,
borderWidth : 1.5 ,
2026-02-02 20:34:35 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
hidden : true ,
2026-02-02 20:34:35 +00:00
yAxisID : 'y1' ,
},
{
2026-04-08 15:13:13 +01:00
label : 'Maths progress' ,
2026-07-20 12:19:37 +01:00
data : col ( 'maths_progress' ),
2026-04-08 15:13:13 +01:00
borderColor : 'rgb(236,72,153)' ,
backgroundColor : 'rgba(236,72,153,0.08)' ,
borderWidth : 1.5 ,
2026-02-02 20:34:35 +00:00
tension : 0.3 ,
2026-04-08 15:13:13 +01:00
pointRadius : 3 ,
hidden : true ,
2026-02-02 20:34:35 +00:00
yAxisID : 'y1' ,
},
];
2026-05-19 12:49:19 +01:00
// ── Mobile chip state + filtered datasets ────────────────────────────
const chips = isSecondary ? SECONDARY_CHIPS : PRIMARY_CHIPS ;
// A chip is enabled only if at least one of its series has any real data.
const chipHasData = ( chip : ChipDef ) =>
chip . series . some ( name => {
const ds = allDatasets . find ( d => d . label === name );
return ds ? . data ? . some ( v => v != null );
});
const firstEnabledChip = chips . find ( chipHasData ) ? . id ?? chips [ 0 ]. id ;
const [ activeChip , setActiveChip ] = useState < ChipId >( firstEnabledChip );
const activeChipDef = chips . find ( c => c . id === activeChip ) ?? chips [ 0 ];
const mobileDatasets = useMemo (() => {
return allDatasets
. filter ( ds => activeChipDef . series . includes ( ds . label ?? '' ))
. map ( ds => ({ ... ds , hidden : false , yAxisID : 'y' as const }));
}, [ activeChipDef , allDatasets ]);
// Auto-scale Y axis for the mobile chart so variation is visible.
// For percentage chips: clamp to 0– 100 but tighten when data sits in a band.
// For progress chips: centre on 0 with a small symmetric range.
const mobileYBounds = useMemo (() => {
const isProgress = activeChip === 'progress' || activeChip === 'progress8' ;
const values : number [] = mobileDatasets . flatMap ( ds =>
( ds . data as Array < number | null | undefined >). filter (( v ) : v is number => typeof v === 'number' )
);
if ( values . length === 0 ) return { min : 0 , max : 100 , isProgress };
const lo = Math . min (... values );
const hi = Math . max (... values );
if ( isProgress ) {
const reach = Math . max ( 2 , Math . ceil ( Math . max ( Math . abs ( lo ), Math . abs ( hi )) + 0.5 ));
return { min : - reach , max : reach , isProgress };
}
// Percentage: leave headroom but never widen below 0 / above 100.
const padded = Math . max ( 5 , Math . round (( hi - lo ) * 0.2 ));
return {
min : Math.max ( 0 , Math . floor (( lo - padded ) / 5 ) * 5 ),
max : Math.min ( 100 , Math . ceil (( hi + padded ) / 5 ) * 5 ),
isProgress ,
};
}, [ activeChip , mobileDatasets ]);
// ── Chart options ────────────────────────────────────────────────────
const desktopOptions : ChartOptions < 'line' > = {
2026-02-02 20:34:35 +00:00
responsive : true ,
maintainAspectRatio : false ,
2026-04-08 15:13:13 +01:00
interaction : { mode : 'index' , intersect : false },
2026-07-20 12:19:37 +01:00
// Never bridge missing years — cancelled/unpublished years are real gaps.
datasets : { line : { spanGaps : false } },
2026-02-02 20:34:35 +00:00
plugins : {
legend : {
2026-04-08 15:13:13 +01:00
position : 'top' ,
2026-02-02 20:34:35 +00:00
labels : {
usePointStyle : true ,
2026-04-08 15:13:13 +01:00
padding : 14 ,
font : { size : 12 },
},
},
title : { display : false },
tooltip : {
backgroundColor : 'rgba(26,22,18,0.92)' ,
padding : 12 ,
titleFont : { size : 13 },
bodyFont : { size : 12 },
callbacks : {
label : ctx => {
const label = ctx . dataset . label ?? '' ;
if ( ctx . parsed . y == null ) return label ;
const isProgress = ctx . dataset . yAxisID === 'y1' ;
const suffix = isProgress ? '' : '%' ;
2026-05-19 12:49:19 +01:00
return ` ${ label } : ${ ctx . parsed . y . toFixed ( 1 ) }${ suffix } ` ;
2026-02-02 20:34:35 +00:00
},
},
},
},
scales : {
y : {
2026-05-19 12:49:19 +01:00
type : 'linear' , display : true , position : 'left' ,
title : { display : true , text : isSecondary ? 'Score / %' : 'Percentage (%)' , font : { size : 11 } },
min : 0 , max : isSecondary ? undefined : 100 ,
2026-04-08 15:13:13 +01:00
grid : { color : 'rgba(0,0,0,0.05)' },
ticks : { font : { size : 11 } },
2026-02-02 20:34:35 +00:00
},
y1 : {
2026-05-19 12:49:19 +01:00
type : 'linear' , display : true , position : 'right' ,
title : { display : true , text : isSecondary ? 'Progress 8' : 'Progress score' , font : { size : 11 } },
2026-04-08 15:13:13 +01:00
grid : { drawOnChartArea : false },
ticks : { font : { size : 11 } },
2026-02-02 20:34:35 +00:00
},
x : {
2026-04-08 15:13:13 +01:00
grid : { display : false },
ticks : { font : { size : 11 } },
2026-02-02 20:34:35 +00:00
},
},
};
2026-05-19 12:49:19 +01:00
const mobileOptions : ChartOptions < 'line' > = {
responsive : true ,
maintainAspectRatio : false ,
interaction : { mode : 'index' , intersect : false },
2026-07-20 12:19:37 +01:00
datasets : { line : { spanGaps : false } },
2026-05-19 12:49:19 +01:00
plugins : {
legend : { display : false },
title : { display : false },
tooltip : {
backgroundColor : 'rgba(26,22,18,0.92)' ,
padding : 10 ,
titleFont : { size : 12 },
bodyFont : { size : 11 },
callbacks : {
label : ctx => {
const label = ctx . dataset . label ?? '' ;
if ( ctx . parsed . y == null ) return label ;
const suffix = mobileYBounds . isProgress ? '' : '%' ;
return ` ${ label } : ${ ctx . parsed . y . toFixed ( 1 ) }${ suffix } ` ;
},
},
},
},
scales : {
y : {
type : 'linear' , display : true , position : 'left' ,
min : mobileYBounds.min , max : mobileYBounds.max ,
grid : { color : 'rgba(0,0,0,0.05)' },
ticks : { font : { size : 10 }, maxTicksLimit : 5 },
},
x : {
grid : { display : false },
2026-07-20 12:19:37 +01:00
// With the gap-honest axis (more year labels) autoSkip keeps the
// phone axis readable; the broken line still shows where a year is
// missing even when its tick label is skipped.
ticks : { font : { size : 10 }, autoSkip : true , maxTicksLimit : 5 , maxRotation : 0 },
2026-05-19 12:49:19 +01:00
},
},
};
const subtitle = isSecondary
? 'GCSE results · Year 11'
: 'KS2 SATs · Reading, Writing & Maths' ;
2026-02-02 20:34:35 +00:00
return (
2026-04-08 15:13:13 +01:00
< div className = { styles . chartOuter }>
{ trendSummary && (
< div className = { styles . trendSummary }>{ trendSummary }</ div >
)}
2026-05-19 12:49:19 +01:00
{ /* Mobile-only chip selector */ }
< div className = { styles . mobileChips } aria-hidden = { ! isMobile }>
< div className = { styles . mobileSubtitle }>{ subtitle }</ div >
< div className = { styles . chipRow } role = "tablist" aria-label = "Select metric" >
{ chips . map ( chip => {
const enabled = chipHasData ( chip );
const active = chip . id === activeChip ;
return (
< button
key = { chip . id }
type = "button"
role = "tab"
aria-selected = { active }
disabled = { ! enabled }
2026-05-19 22:04:22 +01:00
onClick = {() => {
if ( chip . id !== activeChip ) {
track ( 'chart_metric_changed' , { chip : chip.id , phase : isSecondary ? 'secondary' : 'primary' , viewport : 'mobile' });
}
setActiveChip ( chip . id );
}}
2026-05-19 12:49:19 +01:00
className = { ` ${ styles . chip }${ active ? ` ${ styles . chipActive } ` : '' } ` }
title = { ! enabled ? 'No data for this school' : undefined }
>
{ chip . label }
</ button >
);
})}
</ div >
2026-04-08 15:13:13 +01:00
</ div >
2026-05-19 12:49:19 +01:00
< div className = { styles . chartWrapper }>
< Line
data = {{ labels : years , datasets : isMobile ? mobileDatasets : allDatasets }}
options = { isMobile ? mobileOptions : desktopOptions }
/>
</ div >
{ /* When the Progress chip is active on primary, show a tiny inline legend
for the 3 sub-series (reading/writing/maths) — they share a unit and
belong together. */ }
{ isMobile && activeChip === 'progress' && (
< div className = { styles . miniLegend }>
< span >< span className = { styles . miniDot } style = {{ background : 'rgb(59,130,246)' }} /> Reading </ span >
< span >< span className = { styles . miniDot } style = {{ background : 'rgb(139,92,246)' }} /> Writing </ span >
< span >< span className = { styles . miniDot } style = {{ background : 'rgb(236,72,153)' }} /> Maths </ span >
</ div >
)}
2026-07-20 12:19:37 +01:00
{ hasGap && (
2026-04-08 15:13:13 +01:00
< p className = { styles . covidNote }>
2026-07-20 12:19:37 +01:00
{ isSecondary
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available." }
2026-04-08 15:13:13 +01:00
</ p >
)}
2026-05-19 12:49:19 +01:00
{ /* Desktop-only hint about toggling progress in the legend */ }
2026-04-08 15:13:13 +01:00
{ ! isSecondary && (
< p className = { styles . chartHint }>
Progress scores ( Reading , Writing , Maths ) are hidden by default — click them in the legend to show .
</ p >
)}
2026-02-02 20:34:35 +00:00
</ div >
);
}