fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s

S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-17 17:41:12 +01:00
co-authored by Claude Fable 5
parent 1004f08daf
commit f3fa12806b
10 changed files with 124 additions and 17 deletions
+19 -5
View File
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
/** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>;
/** KS4 metrics get a different (honest) gap caption than KS2. */
isSecondary?: boolean;
}
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: true,
title: {
display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: {
size: 12,
weight: 'bold',
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
{built.showUnpublished202122Note && (
{isSecondary && built.englandOnlyYears.length > 0 ? (
// KS4's honest story differs from KS2's: 2019/202020/21 school-level
// GCSE results weren't published (COVID grading); later years WERE
// published by DfE but aren't in our dataset yet.
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
School-level GCSE figures for 2019/20 and 2020/21 weren&apos;t published (COVID
grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p>
) : (
!isSecondary &&
built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
</p>
)
)}
</div>
);
+16 -4
View File
@@ -365,12 +365,18 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison"
>
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
{/* Fills the 200px label rail on desktop (hidden on mobile).
All-through schools must not be miscounted as "primary
schools"/"secondary schools" — mixed baskets get "· primary
view" phrasing instead. */}
<div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school
{activeSchools.length === 1 ? '' : 's'}
{activeSchools.every((sch) =>
sch.phase?.toLowerCase().includes(comparePhase),
)
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
: `${activeSchools.length} schools · ${comparePhase} view`}
</span>
</div>
{activeSchools.map((school, index) => (
@@ -390,7 +396,13 @@ export function ComparisonView({
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a>
<span className={styles.chipMeta}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
{[
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
school.local_authority,
school.school_type,
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
<button
@@ -148,6 +148,15 @@ export function CompareAcademics({
}
return null;
});
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
// sat no KS2 tests (COVID), so there is no baseline to measure progress
// from. A bare "No data" reads as a gap on our side — say why.
const latestYear = urns.reduce((max, urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return Math.max(max, y);
}, 0);
const p8NotPublished = latestYear >= 202425;
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
@@ -189,6 +198,11 @@ export function CompareAcademics({
>
{banding[i]}
</Chip>
) : p8NotPublished ? (
<span className={s.small}>
Not published this GCSE year group sat no KS2 tests (COVID), so DfE has no
baseline to measure progress from
</span>
) : (
<span className={s.small}>No data</span>
)}
@@ -218,6 +232,18 @@ export function CompareAcademics({
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
// pupils the figure rests on.
const eligible = latestValues(data, urns, 'eligible_pupils');
const disadvantagedShare = latestValues(data, urns, 'disadvantaged_pct');
const cohorts = urns.map((_, i) => {
const n = eligible[i];
const share = disadvantagedShare[i];
if (n == null || share == null) return null;
const cohort = Math.round((n * share) / 100);
return cohort > 0 ? cohort : null;
});
return (
<Section
@@ -270,6 +296,9 @@ export function CompareAcademics({
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}%
</span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
@@ -170,8 +170,14 @@ export function CompareAtAGlance({
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
// An all-through school's roll covers every age group, so judging
// it against the single-phase median ("Much larger than average")
// is meaningless — label the roll honestly instead.
const isAllThrough = /all.?through/i.test(school.phase ?? '');
let sizeNote: string | null = null;
if (pupils != null && medianPupils != null) {
if (isAllThrough) {
sizeNote = 'Whole-school roll (all-through, all ages)';
} else if (pupils != null && medianPupils != null) {
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
@@ -48,10 +48,25 @@ export function CompareCommunity({
);
};
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
return (
<Section
title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
how={
<>
The school&apos;s community, from the latest school census. State-school averages are
computed from our dataset and shown for context there&apos;s no &ldquo;right&rdquo;
number here.
{anyAllThrough && (
<>
{' '}
For all-through schools these figures cover the whole school, all ages not just
the {isSecondary ? 'secondary' : 'primary'} phase.
</>
)}
</>
}
>
<SectionGrid schools={schools}>
<Measure label="Pupils on roll">
@@ -119,6 +119,7 @@ export function TrendsExplorer({
metric={metric}
metricLabel={metricLabel}
nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/>
</div>
</div>