Files
school_compare/nextjs-app/components/PerformanceChart.tsx
TudorandClaude Opus 5.5 d1688ac150
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
copy(web): replace em dashes in public copy
Rewrites every visitor-facing string that used an em dash, choosing the
punctuation for what the dash was doing: a colon before a list or
explanation, a comma for an aside, a full stop between two thoughts,
parentheses for an aside mid-sentence. Covers page titles and meta
descriptions, the home and admissions guide copy, school page headings
and notes, the compare page, metric labels and tooltips.

Two rewrites also fix the sentence around them: the closure banner no
longer repeats "proposed for closure", and the cut-off caveat's list of
priorities now parses.

A lone dash marking a missing value in a table cell stays: it is a data
convention, not prose. A Jest guard walks the source with the TypeScript
parser and fails on any other em dash in a string or JSX text node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:04:27 +01:00

468 lines
17 KiB
TypeScript
Raw Permalink 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.
/**
* 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 { useMemo, useState } from 'react';
import { useThemeTokens, alpha } from '@/lib/theme';
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 { fillAcademicYears } from '@/lib/compareChartData';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css';
interface NationalByYear {
year: number;
primary: Record<string, number>;
secondary: Record<string, number>;
}
interface PerformanceChartProps {
data: SchoolResult[];
schoolName: string;
isSecondary?: boolean;
nationalRwmAvg?: number | null;
nationalAtt8Avg?: number | null;
nationalByYear?: NationalByYear[];
}
// 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);
// 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);
const isMobile = useIsMobile();
// ── Build per-year national averages (aligned to the filled axis) ────
const natRefRwm: (number | null)[] = axisYears.map(y => {
if (nationalByYear) {
const match = nationalByYear.find(n => n.year === y);
return match?.primary?.rwm_expected_pct ?? null;
}
return nationalRwmAvg ?? null;
});
const natRefAtt8: (number | null)[] = axisYears.map(y => {
if (nationalByYear) {
const match = nationalByYear.find(n => n.year === y);
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}% met the expected standard in Reading, Writing & Maths`;
}
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
})();
// 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;
// Chart.js paints to a canvas, so it needs resolved colours rather than
// var(). Reading them through the token bridge keeps the series on the
// brand palette and lets them follow the theme.
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
);
// ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = {
borderColor: cRef,
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: [6, 4] as number[],
pointRadius: 0,
tension: 0,
spanGaps: false as const,
order: 10,
};
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
{
label: 'Attainment 8',
data: col('attainment_8_score'),
borderColor: c1,
backgroundColor: alpha('--chart-1', 0.08),
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
},
{
label: 'English & Maths Grade 4+',
data: col('english_maths_standard_pass_pct'),
borderColor: c2,
backgroundColor: alpha('--chart-2', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
yAxisID: 'y',
},
{
label: 'Progress 8',
data: col('progress_8_score'),
borderColor: c3,
backgroundColor: alpha('--chart-3', 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: col('rwm_expected_pct'),
borderColor: c1,
backgroundColor: alpha('--chart-1', 0.08),
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
},
{
label: 'Exceeding expected level',
data: col('rwm_high_pct'),
borderColor: c2,
backgroundColor: alpha('--chart-2', 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: col('reading_progress'),
borderColor: c4,
backgroundColor: alpha('--chart-4', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
hidden: true,
yAxisID: 'y1',
},
{
label: 'Writing progress',
data: col('writing_progress'),
borderColor: c3,
backgroundColor: alpha('--chart-3', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
hidden: true,
yAxisID: 'y1',
},
{
label: 'Maths progress',
data: col('maths_progress'),
borderColor: c5,
backgroundColor: alpha('--chart-5', 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<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'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
// Never bridge missing years — cancelled/unpublished years are real gaps.
datasets: { line: { spanGaps: false } },
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 14,
font: { size: 12 },
},
},
title: { display: false },
tooltip: {
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
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: cGrid },
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 },
datasets: { line: { spanGaps: false } },
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
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: cGrid },
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
},
x: {
grid: { display: false },
// 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 },
},
},
};
const subtitle = isSecondary
? 'GCSE results · Year 11'
: 'KS2 SATs · Reading, Writing & Maths';
return (
<div className={styles.chartOuter}>
{trendSummary && (
<div className={styles.trendSummary}>{trendSummary}</div>
)}
{/* 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}
onClick={() => {
if (chip.id !== activeChip) {
track('chart_metric_changed', { chip: chip.id, phase: isSecondary ? 'secondary' : 'primary', viewport: 'mobile' });
}
setActiveChip(chip.id);
}}
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
title={!enabled ? 'No data for this school' : undefined}
>
{chip.label}
</button>
);
})}
</div>
</div>
<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: c4 }} />Reading</span>
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
</div>
)}
{hasGap && (
<p className={styles.covidNote}>
{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 if available."
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22. Where available, the England average is shown for those years."}
</p>
)}
{/* Desktop-only hint about toggling progress in the legend */}
{!isSecondary && (
<p className={styles.chartHint}>
Progress scores (Reading, Writing, Maths) are hidden by default. Click them in the legend to show them.
</p>
)}
</div>
);
}