/** * PerformanceChart Component * 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. */ 'use client'; import { useEffect, useMemo, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { ChartOptions, ChartDataset } from 'chart.js'; import '@/lib/chartSetup'; import type { SchoolResult } from '@/lib/types'; import { formatAcademicYear } from '@/lib/utils'; import { track } from '@/lib/analytics'; import styles from './PerformanceChart.module.css'; interface NationalByYear { year: number; primary: Record; secondary: Record; } interface PerformanceChartProps { data: SchoolResult[]; schoolName: string; isSecondary?: boolean; nationalRwmAvg?: number | null; nationalAtt8Avg?: number | null; nationalByYear?: NationalByYear[]; } const COVID_YEARS = new Set([201920, 202021]); // 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'] }, ]; 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)); // ── Mobile detection ───────────────────────────────────────────────── // Hydration-safe: SSR renders desktop; client flips to mobile after mount. const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mq = window.matchMedia('(max-width: 640px)'); const update = () => setIsMobile(mq.matches); update(); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, []); // ── Build per-year national averages ───────────────────────────────── 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 — references headline metric) ──────── 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}%`; })(); const hasCovidGap = isSecondary ? false : COVID_YEARS.size > 0 && [...COVID_YEARS].some(y => !sortedData.find(d => d.year === y)); // ── Datasets (full set; mobile filters them via the active chip) ───── 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 allDatasets: 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', }, ]; // ── 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(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).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'> = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'top', labels: { usePointStyle: true, 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 ? '' : '%'; return `${label}: ${ctx.parsed.y.toFixed(1)}${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 } }, }, }, }; const mobileOptions: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, 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 }, ticks: { font: { size: 10 }, autoSkip: false }, }, }, }; const subtitle = isSecondary ? 'GCSE results · Year 11' : 'KS2 SATs · Reading, Writing & Maths'; return (
{trendSummary && (
{trendSummary}
)} {/* Mobile-only chip selector */}
{subtitle}
{chips.map(chip => { const enabled = chipHasData(chip); const active = chip.id === activeChip; return ( ); })}
{/* 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' && (
Reading Writing Maths
)} {hasCovidGap && (

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

)} {/* Desktop-only hint about toggling progress in the legend */} {!isSecondary && (

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

)}
); }