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
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
163 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|