Files
school_compare/nextjs-app/components/PerformanceChart.tsx
T
Tudor Sitaru d569a2afda
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
feat(chart): mobile-only single-metric chip selector for Results Over Time
The Results Over Time chart on the school detail page was overcrowded
on phones — 5-item legend wrapping over the plot area, a hidden right
y-axis still rendered for the (collapsed) progress series, fixed
0–100 percentage scale that flattened all variation, angled x-axis
labels eating vertical space.

At ≤640px the chart now renders one metric at a time, selected via a
chip strip above the plot. No more legend. No more dual y-axis. The
y-axis auto-tightens around the actual data range so variation is
visible (a school sitting in the 70–90% band now uses a 65–95 axis
instead of squashing onto a 0–100 line). A small subtitle above the
chips sets the subject context ("KS2 SATs · Reading, Writing & Maths"
or "GCSE results · Year 11") so chip labels can describe the *view*
rather than re-stating the subject.

Chip labels are spelled out in parent-facing language — no internal
shorthand like "RWM":

  Primary  (KS2):  At expected level (default)
                   Above expected level
                   Pupil progress         (shows 3 series + mini-legend)

  Secondary (KS4): Attainment 8 (default)
                   English & Maths grade 4+
                   Progress 8

Chips disable themselves (greyed, with a "No data for this school"
title) when the underlying series has no data points. Desktop
behaviour is unchanged — the full multi-series chart with dual y-axis
still renders >640px.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:49:19 +01:00

436 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 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}% 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<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={() => 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>
);
}