PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/**
|
||
* 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 { isSpecialSchool } from '@/lib/utils';
|
||
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,
|
||
special,
|
||
}: {
|
||
spec: StripSpec;
|
||
data: Record<string, ComparisonData>;
|
||
urns: number[];
|
||
schoolNames: string[];
|
||
national: Record<string, number> | undefined;
|
||
/** Per-school special-school flag; special schools' mainstream attainment is
|
||
* not a fair comparison, so it's dropped from the strip (no dot). */
|
||
special: boolean[];
|
||
}) {
|
||
const values = latestValues(data, urns, spec.metric).map((v, i) =>
|
||
v != null && !special[i] ? 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 (
|
||
<DotStrip
|
||
label={spec.label}
|
||
values={values}
|
||
schoolNames={schoolNames}
|
||
anchor={anchor}
|
||
min={spec.min ?? 0}
|
||
max={spec.max ?? 100}
|
||
unit={spec.unit ?? '%'}
|
||
tip={spec.tip}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function CompareAcademics({
|
||
schools,
|
||
data,
|
||
nationalAverages,
|
||
benchmarks,
|
||
isSecondary: propIsSecondary,
|
||
}: {
|
||
schools: School[];
|
||
data: Record<string, ComparisonData>;
|
||
nationalAverages?: NationalAverages;
|
||
benchmarks?: Benchmarks;
|
||
isSecondary?: boolean;
|
||
}) {
|
||
const urns = schools.map((school) => school.urn);
|
||
const schoolNames = schools.map((school) => school.school_name);
|
||
// Special schools / PRUs / AP: their pupils sit the same assessments but very
|
||
// few reach the mainstream standard, so their attainment isn't a fair
|
||
// like-for-like comparison — drop it (progress banding, which IS meaningful,
|
||
// is kept).
|
||
const specialFlags = schools.map((school) => isSpecialSchool(school));
|
||
const dropSpecial = (vals: Array<number | null>) =>
|
||
vals.map((v, i) => (specialFlags[i] ? null : v));
|
||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||
);
|
||
|
||
if (isSecondary) {
|
||
const att8 = dropSpecial(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;
|
||
});
|
||
// 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 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
|
||
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
|
||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||
|
||
// Every headline number gets its England anchor + verdict chip, so the
|
||
// "anchored against the England average" promise holds for the grade-5
|
||
// and EBacc rows too, not just Attainment 8.
|
||
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
|
||
if (value == null || anchor == null) return null;
|
||
const v = verdict(value, anchor, tol);
|
||
return (
|
||
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||
</Chip>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Section
|
||
title="How students do academically"
|
||
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally. The wording is DfE's own banding."
|
||
>
|
||
<SectionGrid schools={schools}>
|
||
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
|
||
{schools.map((school, i) => (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{att8[i] != null ? (
|
||
<>
|
||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
|
||
{anchorChip(att8[i], att8Anchor, 2)}
|
||
{att8Anchor != null && (
|
||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
))}
|
||
|
||
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
|
||
{schools.map((school, i) => (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{banding[i] ? (
|
||
<Chip
|
||
tone={
|
||
/well above|above/i.test(banding[i] as string)
|
||
? 'good'
|
||
: /well below|below/i.test(banding[i] as string)
|
||
? 'warn'
|
||
: 'neutral'
|
||
}
|
||
>
|
||
{banding[i]}
|
||
</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>
|
||
)}
|
||
</Cell>
|
||
))}
|
||
|
||
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
|
||
Grade 5+ in English & maths
|
||
</RowLabel>
|
||
{schools.map((school, i) => (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{grade5[i] != null ? (
|
||
<>
|
||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||
{Math.round(grade5[i] as number)}%
|
||
</span>{' '}
|
||
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||
{grade5Anchor != null && (
|
||
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
))}
|
||
|
||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||
{schools.map((school, i) => (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{ebacc[i] != null ? (
|
||
<>
|
||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||
{Math.round(ebacc[i] as number)}%
|
||
</span>{' '}
|
||
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||
{ebaccAnchor != null && (
|
||
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
))}
|
||
</SectionGrid>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
const national = nationalAverages?.primary;
|
||
const disadvantaged = dropSpecial(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. 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 (
|
||
<Section
|
||
title="How children do academically"
|
||
how="Results from national tests and teacher assessments at the end of Year 6 (writing is assessed by teachers, not tested). Each line runs from 0–100%; the grey tick marks the England average, so dots to its right are above average."
|
||
>
|
||
<div className={s.card}>
|
||
{TIER1_PRIMARY.map((spec) => (
|
||
<Strip
|
||
key={spec.metric}
|
||
spec={spec}
|
||
data={data}
|
||
urns={urns}
|
||
schoolNames={schoolNames}
|
||
national={national}
|
||
special={specialFlags}
|
||
/>
|
||
))}
|
||
|
||
<details className={styles.moreMeasures}>
|
||
<summary>More measures: grammar, punctuation & spelling, science, average scaled scores</summary>
|
||
{TIER2_PRIMARY.map((spec) => (
|
||
<Strip
|
||
key={spec.metric}
|
||
spec={spec}
|
||
data={data}
|
||
urns={urns}
|
||
schoolNames={schoolNames}
|
||
national={national}
|
||
special={specialFlags}
|
||
/>
|
||
))}
|
||
<p className={styles.stripNote}>
|
||
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.
|
||
</p>
|
||
</details>
|
||
</div>
|
||
|
||
{disadvantaged.some((v) => v != null) && (
|
||
<SectionGrid schools={schools}>
|
||
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
|
||
Children from lower-income families
|
||
</RowLabel>
|
||
{schools.map((school, i) => {
|
||
const value = disadvantaged[i];
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{value != null ? (
|
||
<>
|
||
<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' &&
|
||
`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`}
|
||
</Chip>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
);
|
||
})}
|
||
</SectionGrid>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|