Files
school_compare/nextjs-app/components/SatsChart.tsx
TudorandClaude Opus 4.8 8a9ba30cc2
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
fix(detail): compare each SATs bar to its own national benchmark
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 14:55:43 +01:00

163 lines
5.0 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import styles from './SatsChart.module.css';
interface SubjectData {
name: string;
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 {
subjects: SubjectData[];
}
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<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 }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount
useEffect(() => {
const bars = [expectedRef.current, exceedingRef.current];
bars.forEach((bar) => {
if (!bar) return;
const target = bar.dataset.width;
bar.style.width = '0%';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bar.style.width = `${target}%`;
});
});
});
}, [expectedPct, exceedingPct]);
if (expectedPct == null && exceedingPct == null) return null;
return (
<div className={styles.subjectChart}>
<div className={styles.subjectName}>{name}</div>
<div className={styles.chartArea}>
{/* Gridlines */}
<div className={styles.gridlines}>
{GRIDLINE_POSITIONS.map((pct) => (
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
))}
</div>
{/* Bars — each carries its own national marker */}
<div className={styles.barGroup}>
{expectedPct != null && (
<BarRow
label="Expected"
value={expectedPct}
national={nationalExpectedPct}
barRef={expectedRef}
barClass={styles.barExpected}
/>
)}
{exceedingPct != null && (
<BarRow
label="Exceeding"
value={exceedingPct}
national={nationalExceedingPct}
barRef={exceedingRef}
barClass={styles.barExceeding}
/>
)}
</div>
{/* Ruler */}
<div className={styles.ruler}>
{RULER_TICKS.map((pct, i) => (
<span key={pct}>
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
<div
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
style={{ left: `${pct}%` }}
>
{pct}%
</div>
</span>
))}
</div>
</div>
</div>
);
}
export default function SatsChart({ subjects }: SatsChartProps) {
const visibleSubjects = subjects.filter(
(s) => s.expectedPct != null || s.exceedingPct != null
);
if (visibleSubjects.length === 0) return null;
return (
<div>
<div className={styles.container}>
{visibleSubjects.map((subject) => (
<SubjectColumn key={subject.name} subject={subject} />
))}
</div>
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average
</div>
</div>
</div>
);
}