/** * PerformanceChart Component * Displays school performance data over time using Chart.js */ 'use client'; import { Line } from 'react-chartjs-2'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ChartOptions, ChartDataset, } from 'chart.js'; import type { SchoolResult } from '@/lib/types'; import { formatAcademicYear } from '@/lib/utils'; import styles from './PerformanceChart.module.css'; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); interface NationalByYear { year: number; primary: Record; secondary: Record; } interface PerformanceChartProps { data: SchoolResult[]; schoolName: string; isSecondary?: boolean; /** National average RWM expected % for the latest year — fallback if no by_year data */ nationalRwmAvg?: number | null; /** National average Attainment 8 for the latest year — fallback if no by_year data */ nationalAtt8Avg?: number | null; /** Per-year national averages — used to draw a changing reference line */ nationalByYear?: NationalByYear[]; } // Academic years when SATs/GCSEs were cancelled due to COVID const COVID_YEARS = new Set([201920, 202021]); export function PerformanceChart({ data, isSecondary = false, nationalRwmAvg, nationalAtt8Avg, nationalByYear, }: PerformanceChartProps) { const sortedData = [...data].sort((a, b) => a.year - b.year); const years = sortedData.map(d => formatAcademicYear(d.year)); // Build per-year national average series aligned to the school's data years. // Falls back to a flat line using the scalar prop if by_year isn't available. const natRefRwm: (number | null)[] = sortedData.map(d => { if (nationalByYear) { const match = nationalByYear.find(n => n.year === d.year); return match?.primary?.rwm_expected_pct ?? null; } return nationalRwmAvg ?? null; }); const natRefAtt8: (number | null)[] = sortedData.map(d => { if (nationalByYear) { const match = nationalByYear.find(n => n.year === d.year); return match?.secondary?.attainment_8_score ?? null; } return nationalAtt8Avg ?? null; }); const hasNatRwm = natRefRwm.some(v => v != null); const hasNatAtt8 = natRefAtt8.some(v => v != null); // ── Trend summary (primary only) ────────────────────────────────────── 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) { return `${arrow} Best year on record — ${latestPct}% Reading, Writing & Maths`; } return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`; })(); // ── COVID gap note ───────────────────────────────────────────────────── const hasCovidGap = isSecondary ? false : COVID_YEARS.size > 0 && [...COVID_YEARS].some(y => !sortedData.find(d => d.year === y)); // ── Datasets ────────────────────────────────────────────────────────── const refLineStyle = { borderColor: 'rgba(90,80,70,0.35)', backgroundColor: 'transparent', borderWidth: 1.5, borderDash: [6, 4] as number[], pointRadius: 0, tension: 0, order: 10, }; const datasets: ChartDataset<'line'>[] = isSecondary ? [ { label: 'Attainment 8', data: sortedData.map(d => d.attainment_8_score), borderColor: '#2d7d7d', backgroundColor: 'rgba(45,125,125,0.08)', borderWidth: 2.5, tension: 0.3, pointRadius: 4, pointHoverRadius: 6, yAxisID: 'y', }, { label: 'English & Maths Grade 4+', data: sortedData.map(d => d.english_maths_standard_pass_pct), borderColor: '#c9a227', backgroundColor: 'rgba(201,162,39,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, yAxisID: 'y', }, { label: 'Progress 8', data: sortedData.map(d => d.progress_8_score), borderColor: 'rgb(139,92,246)', backgroundColor: 'rgba(139,92,246,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, hidden: true, yAxisID: 'y1', }, ...(hasNatAtt8 ? [{ ...refLineStyle, label: 'National average', data: natRefAtt8, yAxisID: 'y', } as ChartDataset<'line'>] : []), ] : [ { label: 'Reading, Writing & Maths expected %', data: sortedData.map(d => d.rwm_expected_pct), borderColor: '#2d7d7d', backgroundColor: 'rgba(45,125,125,0.08)', borderWidth: 2.5, tension: 0.3, pointRadius: 4, pointHoverRadius: 6, yAxisID: 'y', }, { label: 'Exceeding expected level', data: sortedData.map(d => d.rwm_high_pct), borderColor: '#c9a227', backgroundColor: 'rgba(201,162,39,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, yAxisID: 'y', }, ...(hasNatRwm ? [{ ...refLineStyle, label: 'National average', data: natRefRwm, yAxisID: 'y', } as ChartDataset<'line'>] : []), { label: 'Reading progress', data: sortedData.map(d => d.reading_progress), borderColor: 'rgb(59,130,246)', backgroundColor: 'rgba(59,130,246,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, hidden: true, yAxisID: 'y1', }, { label: 'Writing progress', data: sortedData.map(d => d.writing_progress), borderColor: 'rgb(139,92,246)', backgroundColor: 'rgba(139,92,246,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, hidden: true, yAxisID: 'y1', }, { label: 'Maths progress', data: sortedData.map(d => d.maths_progress), borderColor: 'rgb(236,72,153)', backgroundColor: 'rgba(236,72,153,0.08)', borderWidth: 1.5, tension: 0.3, pointRadius: 3, hidden: true, yAxisID: 'y1', }, ]; const options: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'top', labels: { usePointStyle: true, padding: 14, font: { size: 12 }, filter: item => item.text !== 'National average' || true, }, }, 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 ? '' : '%'; const val = ctx.parsed.y.toFixed(1); return `${label}: ${val}${suffix}`; }, }, }, }, scales: { y: { type: 'linear', display: true, position: 'left', title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 }, }, min: 0, max: isSecondary ? undefined : 100, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { font: { size: 11 } }, }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: isSecondary ? 'Progress 8' : 'Progress score', font: { size: 11 }, }, grid: { drawOnChartArea: false }, ticks: { font: { size: 11 } }, }, x: { grid: { display: false }, ticks: { font: { size: 11 } }, }, }, }; return (
{trendSummary && (
{trendSummary}
)}
{hasCovidGap && (

* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.

)} {!isSecondary && (

Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.

)}
); }