From 8a9ba30cc24e29653a72c03c0e817684b7db7c07 Mon Sep 17 00:00:00 2001 From: Tudor Date: Tue, 21 Jul 2026 14:55:43 +0100 Subject: [PATCH] fix(detail): compare each SATs bar to its own national benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KS2 SATs chart drew a single national-average line spanning the full height of each subject's chart area, positioned at the national *expected* value. But the area stacks two bars — Expected and Exceeding — and the higher-standard/greater-depth national is a very different, much lower figure (e.g. reading higher standard ~29% vs expected ~75%). So the line crossed the Exceeding bar at the wrong place, making every school's exceeding result look far below national when it wasn't. The per-subject higher-standard nationals were already computed in the fact_ks2_national_averages mart; they just weren't serialized. Fix: - backend: add reading_high_pct, writing_gd_pct (writing = greater depth) and maths_high_pct to the national-averages payload. - SchoolDetailView: pass a nationalExceedingPct per subject, mapping writing to the greater-depth figure. - SatsChart: replace the single full-height line with a national marker on each bar's own track (coral tick + "nat X%" in the bar header), so Expected and Exceeding each sit against the correct benchmark. KS2 only; the secondary Attainment 8 chart already uses one line for one measure and is untouched. Verified: tsc --noEmit, next build, and backend pytest (national averages marts, incl. a new test guarding the per-subject nationals). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- backend/app.py | 4 + backend/tests/test_national_averages_marts.py | 15 ++++ nextjs-app/components/SatsChart.module.css | 46 ++++++---- nextjs-app/components/SatsChart.tsx | 90 +++++++++++-------- nextjs-app/components/SchoolDetailView.tsx | 4 + 5 files changed, 108 insertions(+), 51 deletions(-) diff --git a/backend/app.py b/backend/app.py index ca1cfe8..3e8d713 100644 --- a/backend/app.py +++ b/backend/app.py @@ -807,6 +807,10 @@ async def get_la_averages(request: Request): _KS2_NATIONAL_METRICS = [ "rwm_expected_pct", "rwm_high_pct", "reading_expected_pct", "writing_expected_pct", "maths_expected_pct", + # Per-subject higher-standard nationals: reading/maths reach the "higher + # standard" in the tests; writing is teacher-assessed at "greater depth" + # (writing_gd_pct). Needed so each SATs bar compares to its own benchmark. + "reading_high_pct", "writing_gd_pct", "maths_high_pct", "gps_expected_pct", "gps_high_pct", "science_expected_pct", "reading_avg_score", "maths_avg_score", "gps_avg_score", "reading_progress", "writing_progress", "maths_progress", diff --git a/backend/tests/test_national_averages_marts.py b/backend/tests/test_national_averages_marts.py index f60bcbf..94cffa4 100644 --- a/backend/tests/test_national_averages_marts.py +++ b/backend/tests/test_national_averages_marts.py @@ -25,6 +25,11 @@ class _Ks2Row: year = LATEST rwm_expected_pct = 62.1 gps_expected_pct = 72.0 + # Per-subject higher-standard nationals — reading/maths reach the higher + # standard, writing is teacher-assessed at greater depth (writing_gd_pct). + reading_high_pct = 29.0 + writing_gd_pct = 13.0 + maths_high_pct = 24.0 class _Ks4Row: @@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload): assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02 +def test_per_subject_higher_standard_nationals_are_surfaced(payload): + # The SATs chart compares each bar to its own benchmark, so the per-subject + # higher-standard / greater-depth nationals must reach the payload — not + # only the combined rwm_high_pct. + body = payload(_StubSession) + assert body["primary"]["reading_high_pct"] == 29.0 + assert body["primary"]["writing_gd_pct"] == 13.0 + assert body["primary"]["maths_high_pct"] == 24.0 + + def test_ks4_secondary_empty_when_mart_missing(payload): # No computed stand-in: the UI labels national figures as official DfE # data, so an empty mart must yield an empty secondary series. diff --git a/nextjs-app/components/SatsChart.module.css b/nextjs-app/components/SatsChart.module.css index 54f9a34..a2ba788 100644 --- a/nextjs-app/components/SatsChart.module.css +++ b/nextjs-app/components/SatsChart.module.css @@ -44,30 +44,46 @@ background: var(--bg-secondary, #f3ede4); } -/* ── National average marker ── */ -.natLine { +/* ── Per-bar national average marker ── + Each bar compares against its own benchmark (expected vs higher standard / + greater depth), so the marker sits on the individual bar's track rather than + as one line spanning both bars. */ +.natTick { position: absolute; - top: 0; - height: calc(100% - 20px); - width: 1.5px; - background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */ - z-index: 2; + top: -3px; + bottom: -3px; + width: 2px; + transform: translateX(-50%); + background: var(--accent-coral, #e07256); + border-radius: 2px; + z-index: 4; pointer-events: none; } -.natPill { +.natTick::before { + content: ''; position: absolute; - top: -10px; + top: -3px; + left: 50%; transform: translateX(-50%); + width: 5px; + height: 5px; + border-radius: 50%; background: var(--accent-coral, #e07256); - color: #fff; +} + +.barHeaderRight { + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.natLabel { font-size: 0.55rem; font-weight: 700; - padding: 0.1rem 0.35rem; - border-radius: 4px; - white-space: nowrap; - z-index: 3; - letter-spacing: 0.02em; + color: var(--accent-coral-dark, #b04a2e); + text-transform: uppercase; + letter-spacing: 0.03em; } /* ── Bar rows ── */ diff --git a/nextjs-app/components/SatsChart.tsx b/nextjs-app/components/SatsChart.tsx index a70badf..f25f0eb 100644 --- a/nextjs-app/components/SatsChart.tsx +++ b/nextjs-app/components/SatsChart.tsx @@ -8,6 +8,9 @@ interface SubjectData { expectedPct: number | null; exceedingPct: number | null; nationalExpectedPct: number | null; + /** National higher-standard / greater-depth benchmark for the exceeding + * bar — a different figure from the expected national. */ + nationalExceedingPct: number | null; } interface SatsChartProps { @@ -17,11 +20,45 @@ interface SatsChartProps { const RULER_TICKS = [0, 25, 50, 75, 100]; const GRIDLINE_POSITIONS = [25, 50, 75]; +/** One results bar with a national marker positioned on its own track, so the + * expected and exceeding bars each compare against their own benchmark. */ +function BarRow({ + label, + value, + national, + barRef, + barClass, +}: { + label: string; + value: number; + national: number | null; + barRef: React.RefObject; + barClass: string; +}) { + return ( +
+
+ {label} + + {national != null && nat {national.toFixed(0)}%} + {value.toFixed(0)}% + +
+
+
+ {national != null && ( + +
+ ); +} + function SubjectColumn({ subject }: { subject: SubjectData }) { const expectedRef = useRef(null); const exceedingRef = useRef(null); - const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject; + const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject; // Animate bars on mount useEffect(() => { @@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) { ))}
- {/* National average marker */} - {nationalExpectedPct != null && ( -
-
{nationalExpectedPct.toFixed(0)}%
-
- )} - - {/* Bars */} + {/* Bars — each carries its own national marker */}
{expectedPct != null && ( -
-
- Expected - {expectedPct.toFixed(0)}% -
-
-
-
-
+ )} {exceedingPct != null && ( -
-
- Exceeding - {exceedingPct.toFixed(0)}% -
-
-
-
-
+ )}
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) { Exceeding / high score
-
+
National average
diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 5d4a45c..1148e52 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -752,18 +752,22 @@ export function SchoolDetailView({ expectedPct: latestResults.reading_expected_pct, exceedingPct: latestResults.reading_high_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct, + nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct, }, { name: 'Writing', expectedPct: latestResults.writing_expected_pct, exceedingPct: latestResults.writing_high_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct, + // Writing's higher level is teacher-assessed "greater depth". + nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct, }, { name: 'Maths', expectedPct: latestResults.maths_expected_pct, exceedingPct: latestResults.maths_high_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct, + nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct, }, ]} />