Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 4.8 17bd4d5a5e fix(detail): gap-honest year axis on the detail-page trend charts (Batch D)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m38s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 12:19:37 +01:00
tudor 452ec77449 Merge pull request 'fix(detail): 'England average' provenance labelling (Batch B)' (#65) from fix/detail-provenance-anchoring into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 48s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 43s
Reviewed-on: #65
2026-07-20 07:52:55 +00:00
tudor aa87fa917d Merge pull request 'fix(detail): Ofsted sentinel codes, sixth form, carried-forward labels (Batch A)' (#64) from fix/detail-ofsted-correctness into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 42s
Reviewed-on: #64
2026-07-20 06:21:06 +00:00
TudorandClaude Fable 5 e36125b24a fix(detail): label official DfE figures as 'England average' (provenance)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m0s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Batch B (points 5-6): the detail page called every national figure a
'National avg'. All of them come from the official-DfE national-averages
marts (KS2/KS4 headlines), so they are England averages — relabel to
match the compare screen's provenance convention ('England average' for
official figures; the detail page has no computed benchmarks, so no
'state-school average (computed)' label is needed). Point 6 (anchoring
every number) is already satisfied on the detail page via DeltaChip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 07:19:22 +01:00
5 changed files with 82 additions and 46 deletions
+18 -7
View File
@@ -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 0100.
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,
},
],
+44 -23
View File
@@ -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({
</div>
)}
{hasCovidGap && (
{hasGap && (
<p className={styles.covidNote}>
* 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."}
</p>
)}
+11 -11
View File
@@ -611,8 +611,8 @@ export function SchoolDetailView({
</h2>
<p className={styles.sectionSubtitle}>
{isSecondary
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
</p>
{/* ── Primary / KS2 content ── */}
@@ -637,7 +637,7 @@ export function SchoolDetailView({
)}
</div>
{primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -659,7 +659,7 @@ export function SchoolDetailView({
)}
</div>
{primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -765,7 +765,7 @@ export function SchoolDetailView({
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
@@ -789,7 +789,7 @@ export function SchoolDetailView({
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -801,7 +801,7 @@ export function SchoolDetailView({
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -957,7 +957,7 @@ export function SchoolDetailView({
)}
</div>
{primaryAvg.eal_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -974,7 +974,7 @@ export function SchoolDetailView({
)}
</div>
{primaryAvg.sen_support_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -1146,7 +1146,7 @@ export function SchoolDetailView({
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
{primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)}
</div>
)}
@@ -1158,7 +1158,7 @@ export function SchoolDetailView({
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
{primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -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 {
@@ -452,7 +452,7 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils. National averages shown for comparison.
GCSE results for Year 11 pupils. England averages shown for comparison.
</p>
{p8Suspended && (
@@ -481,7 +481,7 @@ export function SecondarySchoolDetailView({
)}
</div>
{secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
@@ -521,7 +521,7 @@ export function SecondarySchoolDetailView({
)}
</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
@@ -543,7 +543,7 @@ export function SecondarySchoolDetailView({
)}
</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}