diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts index d181a5d..03c2432 100644 --- a/nextjs-app/__tests__/lib/compareLogic.test.ts +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -229,3 +229,31 @@ describe('stripPositions', () => { expect(pts[0].pos).toBe(0); }); }); + +describe('latestValues', () => { + const data = { + '1': { + yearly_data: [ + { year: 202324, rwm_expected_pct: 75 }, + { year: 202425, rwm_expected_pct: 87 }, + ], + }, + '2': { + yearly_data: [ + { year: 202324, rwm_expected_pct: 82 }, + { year: 202425, rwm_expected_pct: null }, + ], + }, + }; + + it('takes the latest non-null value per school in urn order', async () => { + const { latestValues } = await import('@/lib/compareLogic'); + expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]); + }); + + it('returns null for unknown schools and metrics', async () => { + const { latestValues } = await import('@/lib/compareLogic'); + expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]); + expect(latestValues(data, [1], 'nope')).toEqual([null]); + }); +}); diff --git a/nextjs-app/components/compare/CompareAcademics.module.css b/nextjs-app/components/compare/CompareAcademics.module.css new file mode 100644 index 0000000..42b36ef --- /dev/null +++ b/nextjs-app/components/compare/CompareAcademics.module.css @@ -0,0 +1,18 @@ +.moreMeasures { + margin-top: 0.5rem; + border-top: 1px solid var(--border-light); + padding-top: 0.75rem; +} + +.moreMeasures summary { + cursor: pointer; + font-weight: 600; + font-size: 0.88rem; + color: var(--accent-coral-dark); +} + +.stripNote { + font-size: 0.78rem; + color: var(--text-muted); + margin: 0.5rem 0 0; +} diff --git a/nextjs-app/components/compare/CompareAcademics.tsx b/nextjs-app/components/compare/CompareAcademics.tsx new file mode 100644 index 0000000..d31c348 --- /dev/null +++ b/nextjs-app/components/compare/CompareAcademics.tsx @@ -0,0 +1,292 @@ +/** + * How children do academically — tier-1 dot strips anchored on official + * England averages, tier-2 "More measures" one tap away, equity row against + * the computed state-school benchmark. Copy verbatim from the reviewed + * mockups; teacher-assessed measures are labelled as such. + */ + +'use client'; + +import { latestValues, verdict } from '@/lib/compareLogic'; +import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; +import { DotStrip } from '@/components/DotStrip'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import styles from './CompareAcademics.module.css'; + +interface StripSpec { + label: string; + metric: string; + anchorKey?: string; + tip?: string; + min?: number; + max?: number; + unit?: string; +} + +const TIER1_PRIMARY: StripSpec[] = [ + { + label: 'Reading, writing & maths — expected standard', + metric: 'rwm_expected_pct', + anchorKey: 'rwm_expected_pct', + tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.', + }, + { label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' }, + { + label: 'Writing (teacher-assessed)', + metric: 'writing_expected_pct', + anchorKey: 'writing_expected_pct', + tip: 'Writing is assessed by teachers, not tested.', + }, + { label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' }, + { + label: 'Working at a higher standard than expected', + metric: 'rwm_high_pct', + anchorKey: 'rwm_high_pct', + tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.', + }, +]; + +const TIER2_PRIMARY: StripSpec[] = [ + { + label: 'Grammar, punctuation & spelling — expected standard', + metric: 'gps_expected_pct', + anchorKey: 'gps_expected_pct', + }, + { + label: 'Science — expected standard (teacher-assessed)', + metric: 'science_expected_pct', + anchorKey: 'science_expected_pct', + tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.', + }, + { + label: 'Average scaled score — reading', + metric: 'reading_avg_score', + anchorKey: 'reading_avg_score', + min: 100, + max: 120, + unit: '', + }, + { + label: 'Average scaled score — maths', + metric: 'maths_avg_score', + anchorKey: 'maths_avg_score', + min: 100, + max: 120, + unit: '', + }, + { + label: 'Average scaled score — grammar, punctuation & spelling', + metric: 'gps_avg_score', + anchorKey: 'gps_avg_score', + min: 100, + max: 120, + unit: '', + }, +]; + +function Strip({ + spec, + data, + urns, + schoolNames, + national, +}: { + spec: StripSpec; + data: Record; + urns: number[]; + schoolNames: string[]; + national: Record | undefined; +}) { + const values = latestValues(data, urns, spec.metric).map((v) => + v != null ? Math.round(v) : null, + ); + const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined; + const anchor = + anchorValue != null + ? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` } + : null; + if (values.every((v) => v == null)) return null; + return ( + + ); +} + +export function CompareAcademics({ + schools, + data, + nationalAverages, + benchmarks, +}: { + schools: School[]; + data: Record; + nationalAverages?: NationalAverages; + benchmarks?: Benchmarks; +}) { + const urns = schools.map((school) => school.urn); + const schoolNames = schools.map((school) => school.school_name); + const isSecondary = schools.some( + (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, + ); + + if (isSecondary) { + const att8 = latestValues(data, urns, 'attainment_8_score'); + const banding = urns.map((urn) => { + const rows = data[String(urn)]?.yearly_data ?? []; + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string; + } + return null; + }); + 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; + + return ( +
+ + Attainment 8 + {schools.map((school, i) => ( + + {att8[i] != null ? ( + <> + {(att8[i] as number).toFixed(1)} + {att8Anchor != null && ( + England average {att8Anchor.toFixed(1)} + )} + + ) : ( + No data + )} + + ))} + + Progress 8 + {schools.map((school, i) => ( + + {banding[i] ? ( + + {banding[i]} + + ) : ( + No data + )} + + ))} + + + Grade 5+ in English & maths + + {schools.map((school, i) => ( + + {grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : No data} + + ))} + + EBacc entry + {schools.map((school, i) => ( + + {ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : No data} + + ))} + +
+ ); + } + + const national = nationalAverages?.primary; + const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct'); + const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; + + return ( +
+
+ {TIER1_PRIMARY.map((spec) => ( + + ))} + +
+ More measures — grammar, punctuation & spelling, science, average scaled scores + {TIER2_PRIMARY.map((spec) => ( + + ))} +

+ The scaled-score strips show the 100–120 window of the full 80–120 range; 100 is the + expected standard. Where an England tick is missing, the official figure isn't in + our dataset yet. +

+
+
+ + {disadvantaged.some((v) => v != null) && ( + + + Children from lower-income families + + {schools.map((school, i) => { + const value = disadvantaged[i]; + return ( + + {value != null ? ( + <> + + {Math.round(value)}% + {' '} + {disadvantagedAnchor != null && ( + + {verdict(value, disadvantagedAnchor, 5) === 'above' && + `Well above the ${Math.round(disadvantagedAnchor)}% state-school average`} + {verdict(value, disadvantagedAnchor, 5) === 'close' && + `Around the ${Math.round(disadvantagedAnchor)}% state-school average`} + {verdict(value, disadvantagedAnchor, 5) === 'below' && + `Below the ${Math.round(disadvantagedAnchor)}% state-school average`} + + )} + + ) : ( + No data + )} + + ); + })} + + )} +
+ ); +}