From 17bd4d5a5eb0b12ca79b97db587f14f7d671e85b Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 20 Jul 2026 12:19:37 +0100 Subject: [PATCH] fix(detail): gap-honest year axis on the detail-page trend charts (Batch D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the compare screen's chart-truthfulness rules (spec §8.1) to the school detail page's time-series charts. PerformanceChart (Results Over Time, both phases): - Fill every academic year between the first and last data point via the shared fillAcademicYears helper, so cancelled/unpublished years (2019/20, 2020/21, and — for KS2 — 2021/22) render as real gaps instead of compressed time. Each series and the England overlay map onto this filled axis with null for missing years; spanGaps:false so school lines never bridge a gap. - Replace the primary-only COVID note with a distinct, honest gap caption: KS2 names the cancelled tests plus the unpublished 2021/22 school-level year; KS4 names the unpublished 2019/20–2020/21 GCSE grading years. - Mobile x-axis switches to autoSkip so the longer (gap-honest) axis stays readable; the broken line still marks a missing year even when its tick label is skipped. AdmissionsTrendChart: - Same gap-honest axis + spanGaps:false so a missing admissions year is a real gap, not compressed time. Point 12 (definite canvas heights): desktop is already a definite 280px; fix the secondary detail's mobile .chartContainer, which fixed the outer box at 220px and double-constrained PerformanceChart's own 220px canvas + chip strip (clipping the chips onto the plot) — now height:auto to match the primary view. SatsChart is out of scope (single-year per-subject CSS bars — no year axis, no canvas height to constrain). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../components/AdmissionsTrendChart.tsx | 25 +++++-- nextjs-app/components/PerformanceChart.tsx | 67 ++++++++++++------- .../SecondarySchoolDetailView.module.css | 6 +- 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/nextjs-app/components/AdmissionsTrendChart.tsx b/nextjs-app/components/AdmissionsTrendChart.tsx index caeaf4f..b5d2d71 100644 --- a/nextjs-app/components/AdmissionsTrendChart.tsx +++ b/nextjs-app/components/AdmissionsTrendChart.tsx @@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2'; import { ChartOptions } from 'chart.js'; import '@/lib/chartSetup'; import { formatAcademicYear } from '@/lib/utils'; +import { fillAcademicYears } from '@/lib/compareChartData'; import type { SchoolAdmissions } from '@/lib/types'; import styles from './AdmissionsTrendChart.module.css'; @@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis const pts = history.filter((h) => h.first_preference_offer_pct != null); if (pts.length < 2) return null; - const labels = pts.map((p) => formatAcademicYear(p.year)); - const values = pts.map((p) => p.first_preference_offer_pct as number); - const lastIdx = pts.length - 1; + // Gap-honest axis: every academic year between the first and last data point + // appears, so a missing admissions year renders as a real gap (spanGaps:false) + // rather than compressing time between distant years. + const axisYears = fillAcademicYears(pts.map((p) => p.year)); + const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number])); + const labels = axisYears.map(formatAcademicYear); + const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null); + const present = values + .map((v, i) => (v != null ? i : -1)) + .filter((i) => i >= 0); + const lastIdx = present[present.length - 1]; // Auto-scale with headroom so variation is visible, clamped to 0–100. - const lo = Math.min(...values); - const hi = Math.max(...values); + const numeric = values.filter((v): v is number => v != null); + const lo = Math.min(...numeric); + const hi = Math.max(...numeric); const padded = Math.max(5, Math.round((hi - lo) * 0.25)); const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5); const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5); @@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis label: 'First-choice offer rate', data: values, clip: false as const, + spanGaps: false, borderColor: '#e07256', backgroundColor: 'rgba(224,114,86,0.10)', borderWidth: 2.5, tension: 0.3, fill: true, - pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)), + pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)), pointBackgroundColor: '#e07256', pointBorderColor: '#fff', - pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)), + pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)), pointHoverRadius: 6, }, ], diff --git a/nextjs-app/components/PerformanceChart.tsx b/nextjs-app/components/PerformanceChart.tsx index 153a0d6..2ced5e9 100644 --- a/nextjs-app/components/PerformanceChart.tsx +++ b/nextjs-app/components/PerformanceChart.tsx @@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js'; import '@/lib/chartSetup'; import type { SchoolResult } from '@/lib/types'; import { formatAcademicYear } from '@/lib/utils'; +import { fillAcademicYears } from '@/lib/compareChartData'; import { useIsMobile } from '@/hooks/useIsMobile'; import { track } from '@/lib/analytics'; import styles from './PerformanceChart.module.css'; @@ -35,8 +36,6 @@ interface PerformanceChartProps { 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'; @@ -67,21 +66,33 @@ export function PerformanceChart({ nationalByYear, }: PerformanceChartProps) { const sortedData = [...data].sort((a, b) => a.year - b.year); - const years = sortedData.map(d => formatAcademicYear(d.year)); + + // Gap-honest year axis: every academic year between the first and last data + // point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for + // KS2 — 2021/22) render as real gaps rather than compressed time. School + // lines never bridge these gaps (spanGaps:false below). + const axisYears = fillAcademicYears(sortedData.map(d => d.year)); + const byYear = new Map(sortedData.map(d => [d.year, d])); + const col = (key: keyof SchoolResult): (number | null)[] => + axisYears.map(y => { + const v = byYear.get(y)?.[key]; + return typeof v === 'number' ? v : null; + }); + const years = axisYears.map(formatAcademicYear); const isMobile = useIsMobile(); - // ── Build per-year national averages ───────────────────────────────── - const natRefRwm: (number | null)[] = sortedData.map(d => { + // ── Build per-year national averages (aligned to the filled axis) ──── + const natRefRwm: (number | null)[] = axisYears.map(y => { if (nationalByYear) { - const match = nationalByYear.find(n => n.year === d.year); + const match = nationalByYear.find(n => n.year === y); return match?.primary?.rwm_expected_pct ?? null; } return nationalRwmAvg ?? null; }); - const natRefAtt8: (number | null)[] = sortedData.map(d => { + const natRefAtt8: (number | null)[] = axisYears.map(y => { if (nationalByYear) { - const match = nationalByYear.find(n => n.year === d.year); + const match = nationalByYear.find(n => n.year === y); return match?.secondary?.attainment_8_score ?? null; } return nationalAtt8Avg ?? null; @@ -107,10 +118,11 @@ export function PerformanceChart({ return `${arrow} Reading, Writing & Maths 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)); + // A gap year is any filled axis year the school has no results row for — + // exactly the cancelled/unpublished years the fill introduced. Drives the + // honest note below (KS2 and KS4 have different gap stories). + const gapYears = axisYears.filter(y => !byYear.has(y)); + const hasGap = gapYears.length > 0; // ── Datasets (full set; mobile filters them via the active chip) ───── const refLineStyle = { @@ -120,13 +132,14 @@ export function PerformanceChart({ borderDash: [6, 4] as number[], pointRadius: 0, tension: 0, + spanGaps: false as const, order: 10, }; const allDatasets: ChartDataset<'line'>[] = isSecondary ? [ { label: 'Attainment 8', - data: sortedData.map(d => d.attainment_8_score), + data: col('attainment_8_score'), borderColor: '#2d7d7d', backgroundColor: 'rgba(45,125,125,0.08)', borderWidth: 2.5, @@ -137,7 +150,7 @@ export function PerformanceChart({ }, { label: 'English & Maths Grade 4+', - data: sortedData.map(d => d.english_maths_standard_pass_pct), + data: col('english_maths_standard_pass_pct'), borderColor: '#c9a227', backgroundColor: 'rgba(201,162,39,0.08)', borderWidth: 1.5, @@ -147,7 +160,7 @@ export function PerformanceChart({ }, { label: 'Progress 8', - data: sortedData.map(d => d.progress_8_score), + data: col('progress_8_score'), borderColor: 'rgb(139,92,246)', backgroundColor: 'rgba(139,92,246,0.08)', borderWidth: 1.5, @@ -165,7 +178,7 @@ export function PerformanceChart({ ] : [ { label: 'Reading, Writing & Maths expected %', - data: sortedData.map(d => d.rwm_expected_pct), + data: col('rwm_expected_pct'), borderColor: '#2d7d7d', backgroundColor: 'rgba(45,125,125,0.08)', borderWidth: 2.5, @@ -176,7 +189,7 @@ export function PerformanceChart({ }, { label: 'Exceeding expected level', - data: sortedData.map(d => d.rwm_high_pct), + data: col('rwm_high_pct'), borderColor: '#c9a227', backgroundColor: 'rgba(201,162,39,0.08)', borderWidth: 1.5, @@ -192,7 +205,7 @@ export function PerformanceChart({ } as ChartDataset<'line'>] : []), { label: 'Reading progress', - data: sortedData.map(d => d.reading_progress), + data: col('reading_progress'), borderColor: 'rgb(59,130,246)', backgroundColor: 'rgba(59,130,246,0.08)', borderWidth: 1.5, @@ -203,7 +216,7 @@ export function PerformanceChart({ }, { label: 'Writing progress', - data: sortedData.map(d => d.writing_progress), + data: col('writing_progress'), borderColor: 'rgb(139,92,246)', backgroundColor: 'rgba(139,92,246,0.08)', borderWidth: 1.5, @@ -214,7 +227,7 @@ export function PerformanceChart({ }, { label: 'Maths progress', - data: sortedData.map(d => d.maths_progress), + data: col('maths_progress'), borderColor: 'rgb(236,72,153)', backgroundColor: 'rgba(236,72,153,0.08)', borderWidth: 1.5, @@ -275,6 +288,8 @@ export function PerformanceChart({ responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, + // Never bridge missing years — cancelled/unpublished years are real gaps. + datasets: { line: { spanGaps: false } }, plugins: { legend: { position: 'top', @@ -326,6 +341,7 @@ export function PerformanceChart({ responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, + datasets: { line: { spanGaps: false } }, plugins: { legend: { display: false }, title: { display: false }, @@ -353,7 +369,10 @@ export function PerformanceChart({ }, x: { grid: { display: false }, - ticks: { font: { size: 10 }, autoSkip: false }, + // With the gap-honest axis (more year labels) autoSkip keeps the + // phone axis readable; the broken line still shows where a year is + // missing even when its tick label is skipped. + ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 }, }, }, }; @@ -416,9 +435,11 @@ export function PerformanceChart({ )} - {hasCovidGap && ( + {hasGap && (

- * No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19. + {isSecondary + ? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available." + : "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}

)} diff --git a/nextjs-app/components/SecondarySchoolDetailView.module.css b/nextjs-app/components/SecondarySchoolDetailView.module.css index 6ac06bf..396fca1 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.module.css +++ b/nextjs-app/components/SecondarySchoolDetailView.module.css @@ -1049,8 +1049,12 @@ font-size: 1.85rem; } + /* On mobile let the chart container flow naturally — PerformanceChart's + own .chartWrapper carries the definite canvas height (220px) plus the + chip strip above it. A fixed 220px here double-constrained the two and + clipped the chips onto the plot area. */ .chartContainer { - height: 220px; + height: auto; } .dataTable {