dfa8058efc
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
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
History chart
- Strip the redundant school-name title (already the page heading)
- Default to 2 visible lines: Reading, Writing & Maths expected % (teal,
bold) + Exceeding (gold, lighter); progress score lines hidden by
default, togglable via legend
- Add dashed national average reference line for RWM (primary) or
Attainment 8 (secondary) so the school's trajectory is always in
context
- Add trend summary chip above the chart computed from the data
("↓ Peaked at 90% (2016/17), currently 70%")
- Add COVID footnote when 2019/20 and 2020/21 data is absent
Ofsted section
- Collapse the four identical "Outstanding / Outstanding / Outstanding /
Outstanding" boxes into a single prose line when all sub-grades match
the overall verdict; show individual cards only when grades differ
SATs sub-metrics
- DeltaChip vs national average on Expected level row for Reading,
Writing and Maths (national averages already in the API response)
Admissions
- Fix label: "Year 3 places per year" → "Reception places per year" for
primary schools
Pupils & Inclusion
- DeltaChip + national avg hint on Eligible for pupil premium and
Pupils receiving SEN support (both keys present in /api/national-averages)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
8.1 KiB
TypeScript
273 lines
8.1 KiB
TypeScript
/**
|
|
* 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 PerformanceChartProps {
|
|
data: SchoolResult[];
|
|
schoolName: string;
|
|
isSecondary?: boolean;
|
|
/** National average RWM expected % — rendered as a dashed reference line */
|
|
nationalRwmAvg?: number | null;
|
|
/** National average Attainment 8 — rendered as a dashed reference line */
|
|
nationalAtt8Avg?: number | null;
|
|
}
|
|
|
|
// 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,
|
|
}: PerformanceChartProps) {
|
|
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
|
const years = sortedData.map(d => formatAcademicYear(d.year));
|
|
|
|
// ── 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',
|
|
},
|
|
...(nationalAtt8Avg != null ? [{
|
|
...refLineStyle,
|
|
label: 'National average',
|
|
data: sortedData.map(() => nationalAtt8Avg!),
|
|
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',
|
|
},
|
|
...(nationalRwmAvg != null ? [{
|
|
...refLineStyle,
|
|
label: 'National average',
|
|
data: sortedData.map(() => nationalRwmAvg!),
|
|
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 (
|
|
<div className={styles.chartOuter}>
|
|
{trendSummary && (
|
|
<div className={styles.trendSummary}>{trendSummary}</div>
|
|
)}
|
|
<div className={styles.chartWrapper}>
|
|
<Line data={{ labels: years, datasets }} options={options} />
|
|
</div>
|
|
{hasCovidGap && (
|
|
<p className={styles.covidNote}>
|
|
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
|
</p>
|
|
)}
|
|
{!isSecondary && (
|
|
<p className={styles.chartHint}>
|
|
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|