Files
school_compare/nextjs-app/components/PerformanceChart.tsx
T
Tudor Sitaru 87442788d4
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
fix(chart): name the metric in the primary trend summary
The "Peaked at X%" hint didn't say which metric it referenced, leaving
parents to guess. Both branches now lead with "Reading, Writing & Maths".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:16:31 +01:00

442 lines
15 KiB
TypeScript
Raw 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 { 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<string, number>;
secondary: Record<string, number>;
}
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}% met the expected standard in Reading, Writing & Maths`;
}
return `${arrow} Reading, Writing & Maths 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<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 0100 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 },
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 (
<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: '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>
)}
{hasCovidGap && (
<p className={styles.covidNote}>
* No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19.
</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.
</p>
)}
</div>
);
}