diff --git a/nextjs-app/components/PerformanceChart.module.css b/nextjs-app/components/PerformanceChart.module.css index fa4c34e..854bd96 100644 --- a/nextjs-app/components/PerformanceChart.module.css +++ b/nextjs-app/components/PerformanceChart.module.css @@ -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; diff --git a/nextjs-app/components/PerformanceChart.tsx b/nextjs-app/components/PerformanceChart.tsx index b1a4f2f..300a3f1 100644 --- a/nextjs-app/components/PerformanceChart.tsx +++ b/nextjs-app/components/PerformanceChart.tsx @@ -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 ( -
- +
+ {trendSummary && ( +
{trendSummary}
+ )} +
+ +
+ {hasCovidGap && ( +

+ * No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19. +

+ )} + {!isSecondary && ( +

+ Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show. +

+ )}
); } diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index 8367523..97fdca4 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -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; diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 6dc8932..ae3913f 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -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,24 +398,30 @@ export function SchoolDetailView({ {Math.round(parentView.q_recommend_pct)}% of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)

)} -
- {[ - { label: 'Quality of Teaching', value: ofsted.quality_of_education }, - { label: 'Behaviour in School', value: ofsted.behaviour_attitudes }, - { label: 'Pupils\' Wider Development', value: ofsted.personal_development }, - { label: 'School Leadership', value: ofsted.leadership_management }, - ...(ofsted.early_years_provision != null - ? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }] - : []), - ].map(({ label, value }) => value != null && ( -
-
{label}
-
- {OFSTED_LABELS[value]} + {oeifAllSameGrade ? ( +

+ Rated {OFSTED_LABELS[ofsted.overall_effectiveness!]} across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership. +

+ ) : ( +
+ {[ + { label: 'Quality of Teaching', value: ofsted.quality_of_education }, + { label: 'Behaviour in School', value: ofsted.behaviour_attitudes }, + { label: 'Pupils\' Wider Development', value: ofsted.personal_development }, + { label: 'School Leadership', value: ofsted.leadership_management }, + ...(ofsted.early_years_provision != null + ? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }] + : []), + ].map(({ label, value }) => value != null && ( +
+
{label}
+
+ {OFSTED_LABELS[value]} +
-
- ))} -
+ ))} +
+ )} )} @@ -513,7 +532,12 @@ export function SchoolDetailView({ {latestResults.reading_expected_pct !== null && (
Expected level - {formatPercentage(latestResults.reading_expected_pct)} + + {formatPercentage(latestResults.reading_expected_pct)} + {primaryAvg.reading_expected_pct != null && ( + + )} +
)} {latestResults.reading_high_pct !== null && ( @@ -551,7 +575,12 @@ export function SchoolDetailView({ {latestResults.writing_expected_pct !== null && (
Expected level - {formatPercentage(latestResults.writing_expected_pct)} + + {formatPercentage(latestResults.writing_expected_pct)} + {primaryAvg.writing_expected_pct != null && ( + + )} +
)} {latestResults.writing_high_pct !== null && ( @@ -580,7 +609,12 @@ export function SchoolDetailView({ {latestResults.maths_expected_pct !== null && (
Expected level - {formatPercentage(latestResults.maths_expected_pct)} + + {formatPercentage(latestResults.maths_expected_pct)} + {primaryAvg.maths_expected_pct != null && ( + + )} +
)} {latestResults.maths_high_pct !== null && ( @@ -792,7 +826,7 @@ export function SchoolDetailView({
{admissions.published_admission_number != null && (
-
{isSecondary ? 'Year 7' : 'Year 3'} places per year
+
{isSecondary ? 'Year 7' : 'Reception'} places per year
{admissions.published_admission_number}
)} @@ -820,8 +854,13 @@ export function SchoolDetailView({ {latestResults?.disadvantaged_pct != null && (
Eligible for pupil premium
-
{formatPercentage(latestResults.disadvantaged_pct)}
-
Pupils from disadvantaged backgrounds
+
+ {formatPercentage(latestResults.disadvantaged_pct)} + {primaryAvg.disadvantaged_pct != null && ( + + )} +
+
Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}
)} {latestResults?.eal_pct != null && ( @@ -839,7 +878,15 @@ export function SchoolDetailView({ Pupils receiving SEN support
-
{formatPercentage(latestResults.sen_support_pct)}
+
+ {formatPercentage(latestResults.sen_support_pct)} + {primaryAvg.sen_support_pct != null && ( + + )} +
+ {primaryAvg.sen_support_pct != null && ( +
National avg: {primaryAvg.sen_support_pct.toFixed(0)}%
+ )}
)}
@@ -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} /> {yearlyData.length > 1 && (