Merge pull request 'fix(detail): compare each SATs bar to its own national benchmark' (#76) from fix/sats-per-level-national into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-07-21 13:56:55 +00:00
5 changed files with 108 additions and 51 deletions
+4
View File
@@ -807,6 +807,10 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [ _KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_pct", "rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_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", "gps_expected_pct", "gps_high_pct", "science_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score", "reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress", "reading_progress", "writing_progress", "maths_progress",
@@ -25,6 +25,11 @@ class _Ks2Row:
year = LATEST year = LATEST
rwm_expected_pct = 62.1 rwm_expected_pct = 62.1
gps_expected_pct = 72.0 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: 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 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): def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE # No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series. # data, so an empty mart must yield an empty secondary series.
+31 -15
View File
@@ -44,30 +44,46 @@
background: var(--bg-secondary, #f3ede4); background: var(--bg-secondary, #f3ede4);
} }
/* ── National average marker ── */ /* ── Per-bar national average marker ──
.natLine { 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; position: absolute;
top: 0; top: -3px;
height: calc(100% - 20px); bottom: -3px;
width: 1.5px; width: 2px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */ transform: translateX(-50%);
z-index: 2; background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
pointer-events: none; pointer-events: none;
} }
.natPill { .natTick::before {
content: '';
position: absolute; position: absolute;
top: -10px; top: -3px;
left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256); background: var(--accent-coral, #e07256);
color: #fff; }
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
padding: 0.1rem 0.35rem; color: var(--accent-coral-dark, #b04a2e);
border-radius: 4px; text-transform: uppercase;
white-space: nowrap; letter-spacing: 0.03em;
z-index: 3;
letter-spacing: 0.02em;
} }
/* ── Bar rows ── */ /* ── Bar rows ── */
+52 -34
View File
@@ -8,6 +8,9 @@ interface SubjectData {
expectedPct: number | null; expectedPct: number | null;
exceedingPct: number | null; exceedingPct: number | null;
nationalExpectedPct: 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 { interface SatsChartProps {
@@ -17,11 +20,45 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100]; const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75]; 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<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) { function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null); const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null); const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject; const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount // Animate bars on mount
useEffect(() => { useEffect(() => {
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))} ))}
</div> </div>
{/* National average marker */} {/* Bars — each carries its own national marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}> <div className={styles.barGroup}>
{expectedPct != null && ( {expectedPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Expected"
<span className={styles.barLabelSuffix}>Expected</span> value={expectedPct}
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span> national={nationalExpectedPct}
</div> barRef={expectedRef}
<div className={styles.barTrack}> barClass={styles.barExpected}
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/> />
</div>
</div>
)} )}
{exceedingPct != null && ( {exceedingPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Exceeding"
<span className={styles.barLabelSuffix}>Exceeding</span> value={exceedingPct}
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span> national={nationalExceedingPct}
</div> barRef={exceedingRef}
<div className={styles.barTrack}> barClass={styles.barExceeding}
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/> />
</div>
</div>
)} )}
</div> </div>
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score Exceeding / high score
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} /> <div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average National average
</div> </div>
</div> </div>
@@ -752,18 +752,22 @@ export function SchoolDetailView({
expectedPct: latestResults.reading_expected_pct, expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct, exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
}, },
{ {
name: 'Writing', name: 'Writing',
expectedPct: latestResults.writing_expected_pct, expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct, exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_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', name: 'Maths',
expectedPct: latestResults.maths_expected_pct, expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct, exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
}, },
]} ]}
/> />