feat(school-detail): page-wide improvements across 5 sections
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
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>
This commit is contained in:
@@ -16,217 +16,257 @@ import {
|
||||
Tooltip,
|
||||
Legend,
|
||||
ChartOptions,
|
||||
ChartDataset,
|
||||
} from 'chart.js';
|
||||
import type { SchoolResult } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import styles from './PerformanceChart.module.css';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
export function PerformanceChart({ data, schoolName, isSecondary = false }: PerformanceChartProps) {
|
||||
// Sort data by year
|
||||
// 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));
|
||||
|
||||
// Prepare datasets — phase-aware
|
||||
const datasets = isSecondary ? [
|
||||
// ── 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: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
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: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
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(245, 158, 11)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
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 %',
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: sortedData.map(d => d.rwm_expected_pct),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'Reading, Writing & Maths Higher %',
|
||||
label: 'Exceeding expected level',
|
||||
data: sortedData.map(d => d.rwm_high_pct),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
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',
|
||||
label: 'Reading progress',
|
||||
data: sortedData.map(d => d.reading_progress),
|
||||
borderColor: 'rgb(245, 158, 11)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
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',
|
||||
label: 'Writing progress',
|
||||
data: sortedData.map(d => d.writing_progress),
|
||||
borderColor: 'rgb(139, 92, 246)',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
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',
|
||||
label: 'Maths progress',
|
||||
data: sortedData.map(d => d.maths_progress),
|
||||
borderColor: 'rgb(236, 72, 153)',
|
||||
backgroundColor: 'rgba(236, 72, 153, 0.1)',
|
||||
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 chartData = {
|
||||
labels: years,
|
||||
datasets,
|
||||
};
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 12,
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `${schoolName} - Performance Over Time`,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold',
|
||||
},
|
||||
padding: {
|
||||
bottom: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
if (context.dataset.yAxisID === 'y1') {
|
||||
// Progress scores
|
||||
label += context.parsed.y.toFixed(1);
|
||||
} else {
|
||||
// Percentages
|
||||
label += context.parsed.y.toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: isSecondary ? 'Score / Percentage (%)' : 'Percentage (%)',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
text: isSecondary ? 'Score / %' : 'Percentage (%)',
|
||||
font: { size: 11 },
|
||||
},
|
||||
min: 0,
|
||||
max: isSecondary ? undefined : 100,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: isSecondary ? 'Progress 8 Score' : 'Progress Score',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
text: isSecondary ? 'Progress 8' : 'Progress score',
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Year',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
},
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={chartData} options={options} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user