fix(compare): expert should-fixes S1-S4, S6 (S5/S7 closed by verification) #55

Merged
tudor merged 2 commits from fix/expert-signoff-shouldfixes into main 2026-07-17 17:53:16 +00:00
10 changed files with 132 additions and 17 deletions
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7); expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
}); });
it('lists England-only years so the component can caption dashed-only stretches', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
});
expect(chart.englandOnlyYears).toEqual([202122]);
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(none.englandOnlyYears).toEqual([]);
});
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => { it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', { const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7, 202122: 58.7,
@@ -177,6 +177,16 @@ describe('summariseAdmissions', () => {
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' }); expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
}); });
it('60% → "About 1 in 3 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
});
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
});
it('100% → "All first choices offered"', () => { it('100% → "All first choices offered"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 })); const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' }); expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
+19 -5
View File
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
/** Official England figure per academic year for this metric — renders a /** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */ * dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>; 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 // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone. // converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; 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 isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null); const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: true, display: true,
title: { title: {
display: !isMobile, display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
<div className={styles.canvasBox}> <div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} /> <Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div> </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}> <p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish School-level GCSE figures for 2019/20 and 2020/21 weren&apos;t published (COVID
school-level figures for 2021/22 the England average is shown for that year. grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p> </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> </div>
); );
+16 -4
View File
@@ -365,12 +365,18 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties} style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison" 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}> <div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span> <span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}> <span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school {activeSchools.every((sch) =>
{activeSchools.length === 1 ? '' : 's'} sch.phase?.toLowerCase().includes(comparePhase),
)
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
: `${activeSchools.length} schools · ${comparePhase} view`}
</span> </span>
</div> </div>
{activeSchools.map((school, index) => ( {activeSchools.map((school, index) => (
@@ -390,7 +396,13 @@ export function ComparisonView({
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span> <span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a> </a>
<span className={styles.chipMeta}> <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>
</span> </span>
<button <button
@@ -148,6 +148,17 @@ export function CompareAcademics({
} }
return null; 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. Judged
// PER SCHOOL on its own latest data year: a school whose data simply
// stops earlier (an unrelated gap) must not borrow the COVID explanation
// from a neighbour that does have 2024/25 data.
const p8NotPublished = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct'); const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct'); const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score; const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
@@ -189,6 +200,11 @@ export function CompareAcademics({
> >
{banding[i]} {banding[i]}
</Chip> </Chip>
) : p8NotPublished[i] ? (
<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> <span className={s.small}>No data</span>
)} )}
@@ -218,6 +234,24 @@ export function CompareAcademics({
const national = nationalAverages?.primary; const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct'); const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; 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. Taken from the SAME yearly row that supplies
// the displayed percentage: resolving eligible_pupils and the
// disadvantaged share independently could mix years and misstate the
// cohort behind the figure.
const cohorts = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.rwm_expected_disadvantaged_pct != null) {
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
return cohort > 0 ? cohort : null;
}
}
return null;
});
return ( return (
<Section <Section
@@ -270,6 +304,9 @@ export function CompareAcademics({
<span className={s.big} style={{ fontSize: '1.1rem' }}> <span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}% {Math.round(value)}%
</span>{' '} </span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && ( {disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}> <Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' && {verdict(value, disadvantagedAnchor, 5) === 'above' &&
@@ -170,8 +170,14 @@ export function CompareAtAGlance({
{schools.map((school, i) => { {schools.map((school, i) => {
const census = data[String(school.urn)]?.census; const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null; 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; 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'; if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average'; else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller 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 ( return (
<Section <Section
title="Who goes there" 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}> <SectionGrid schools={schools}>
<Measure label="Pupils on roll"> <Measure label="Pupils on roll">
@@ -119,6 +119,7 @@ export function TrendsExplorer({
metric={metric} metric={metric}
metricLabel={metricLabel} metricLabel={metricLabel}
nationalByYear={nationalByYear} nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/> />
</div> </div>
</div> </div>
+11 -6
View File
@@ -53,6 +53,10 @@ export interface CompareChart {
/** True when England published a 2021/22 figure but no school has one — /** True when England published a 2021/22 figure but no school has one —
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */ * the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean; showUnpublished202122Note: boolean;
/** Years where the England overlay has a value but no school does — the
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
* KS4 have different honest explanations, so the component owns the copy). */
englandOnlyYears: number[];
} }
export function buildCompareChart( export function buildCompareChart(
@@ -92,11 +96,12 @@ export function buildCompareChart(
} }
} }
const idx202122 = years.indexOf(202122); const englandOnlyYears = years.filter(
const showUnpublished202122Note = (year, i) =>
idx202122 >= 0 && englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
englandDataset?.data[idx202122] != null && );
schoolDatasets.every((ds) => ds.data[idx202122] == null);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note }; const showUnpublished202122Note = englandOnlyYears.includes(202122);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
} }
+6
View File
@@ -192,6 +192,12 @@ export function summariseAdmissions(
if (pct != null) { if (pct != null) {
if (pct >= 100) { if (pct >= 100) {
chip = { tone: 'good', text: 'All first choices offered' }; chip = { tone: 'good', text: 'All first choices offered' };
} else if (pct < 50) {
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
// than half missed out understated the worst cases by half.
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
} else if (pct < 67) {
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
} else if (pct < 75) { } else if (pct < 75) {
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' }; chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
} else { } else {