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

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:
Tudor Sitaru
2026-04-08 15:13:13 +01:00
parent 41cefeedf6
commit dfa8058efc
4 changed files with 269 additions and 144 deletions
@@ -1,9 +1,34 @@
.chartOuter {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.trendSummary {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #5c564d);
padding: 0.5rem 0.875rem;
background: var(--bg-secondary, #f3ede4);
border-left: 3px solid var(--accent-teal, #2d7d7d);
border-radius: 0 4px 4px 0;
align-self: flex-start;
}
.chartWrapper {
width: 100%;
height: 100%;
position: relative;
}
.covidNote,
.chartHint {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
margin: 0;
font-style: italic;
}
@media (max-width: 768px) {
.chartWrapper {
font-size: 0.875rem;
+154 -114
View File
@@ -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)',
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)',
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,
},
},
},
title: {
display: true,
text: `${schoolName} - Performance Over Time`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
padding: 14,
font: { size: 12 },
filter: item => item.text !== 'National average' || true,
},
},
title: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
backgroundColor: 'rgba(26,22,18,0.92)',
padding: 12,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
titleFont: { size: 13 },
bodyFont: { size: 12 },
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;
}
}
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' 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.chartOuter}>
{trendSummary && (
<div className={styles.trendSummary}>{trendSummary}</div>
)}
<div className={styles.chartWrapper}>
<Line data={chartData} options={options} />
<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>
);
}
@@ -635,6 +635,17 @@
}
/* Progress note */
.ofstedAllSame {
font-size: 0.9375rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
line-height: 1.5;
}
.ofstedAllSame strong {
color: var(--text-primary, #1a1612);
}
.progressNote {
margin-top: 0.75rem;
font-size: 0.8rem;
+56 -7
View File
@@ -138,6 +138,19 @@ export function SchoolDetailView({
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => {
if (!ofsted || ofsted.framework === 'ReportCard') return false;
const subs = [
ofsted.quality_of_education,
ofsted.behaviour_attitudes,
ofsted.personal_development,
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
// ── Hero: framework-aware signal chip + narrative summary ─────────────
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
const heroSummary = buildSchoolSummary(schoolInfo, ofsted, admissions, latestResults);
@@ -385,6 +398,11 @@ export function SchoolDetailView({
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
@@ -403,6 +421,7 @@ export function SchoolDetailView({
</div>
))}
</div>
)}
</>
)}
</section>
@@ -513,7 +532,12 @@ export function SchoolDetailView({
{latestResults.reading_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_expected_pct)}</span>
<span className={styles.metricValue}>
{formatPercentage(latestResults.reading_expected_pct)}
{primaryAvg.reading_expected_pct != null && (
<DeltaChip value={latestResults.reading_expected_pct} baseline={primaryAvg.reading_expected_pct} unit="pts" size="sm" />
)}
</span>
</div>
)}
{latestResults.reading_high_pct !== null && (
@@ -551,7 +575,12 @@ export function SchoolDetailView({
{latestResults.writing_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_expected_pct)}</span>
<span className={styles.metricValue}>
{formatPercentage(latestResults.writing_expected_pct)}
{primaryAvg.writing_expected_pct != null && (
<DeltaChip value={latestResults.writing_expected_pct} baseline={primaryAvg.writing_expected_pct} unit="pts" size="sm" />
)}
</span>
</div>
)}
{latestResults.writing_high_pct !== null && (
@@ -580,7 +609,12 @@ export function SchoolDetailView({
{latestResults.maths_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_expected_pct)}</span>
<span className={styles.metricValue}>
{formatPercentage(latestResults.maths_expected_pct)}
{primaryAvg.maths_expected_pct != null && (
<DeltaChip value={latestResults.maths_expected_pct} baseline={primaryAvg.maths_expected_pct} unit="pts" size="sm" />
)}
</span>
</div>
)}
{latestResults.maths_high_pct !== null && (
@@ -792,7 +826,7 @@ export function SchoolDetailView({
<div className={styles.metricsGrid}>
{admissions.published_admission_number != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Year 3'} places per year</div>
<div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Reception'} places per year</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div>
)}
@@ -820,8 +854,13 @@ export function SchoolDetailView({
{latestResults?.disadvantaged_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Eligible for pupil premium</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.disadvantaged_pct)}</div>
<div className={styles.metricHint}>Pupils from disadvantaged backgrounds</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.disadvantaged_pct)}
{primaryAvg.disadvantaged_pct != null && (
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
)}
</div>
<div className={styles.metricHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
</div>
)}
{latestResults?.eal_pct != null && (
@@ -839,7 +878,15 @@ export function SchoolDetailView({
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.sen_support_pct)}
{primaryAvg.sen_support_pct != null && (
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
)}
</div>
{primaryAvg.sen_support_pct != null && (
<div className={styles.metricHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
@@ -945,6 +992,8 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
/>
</div>
{yearlyData.length > 1 && (