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>
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
/**
|
||
* GcseSection — KS4 headline results. Secondary pages. Server component.
|
||
*/
|
||
|
||
import type { School, SchoolResult } from '@/lib/types';
|
||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||
import { MetricTooltip } from '../MetricTooltip';
|
||
import { DeltaChip } from '../DeltaChip';
|
||
import { SpecialSchoolNote } from '../SpecialSchoolNote';
|
||
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
|
||
|
||
export function GcseSection({
|
||
latestResults, schoolInfo, secondaryAvg, p8Suspended, suppressComparison,
|
||
}: {
|
||
latestResults: SchoolResult;
|
||
schoolInfo: School;
|
||
secondaryAvg: Record<string, number>;
|
||
p8Suspended: boolean;
|
||
suppressComparison: boolean;
|
||
}) {
|
||
return (
|
||
<section id="gcse" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>
|
||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||
</h2>
|
||
<p className={styles.sectionSubtitle}>
|
||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||
</p>
|
||
|
||
<SpecialSchoolNote school={schoolInfo} />
|
||
|
||
{p8Suspended && (
|
||
<div className={styles.p8Banner}>
|
||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||
(COVID), so DfE has no starting point to measure their progress from.
|
||
</div>
|
||
)}
|
||
|
||
{/* Hero stat cards — top GCSE metrics */}
|
||
<div className={styles.heroStatGrid}>
|
||
{latestResults.attainment_8_score != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
Attainment 8 score
|
||
<MetricTooltip metricKey="attainment_8_score" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{latestResults.attainment_8_score.toFixed(1)}
|
||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||
<DeltaChip
|
||
value={latestResults.attainment_8_score}
|
||
baseline={secondaryAvg.attainment_8_score}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults.progress_8_score != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
Progress 8 score
|
||
<MetricTooltip metricKey="progress_8_score" />
|
||
</div>
|
||
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score)}`}>
|
||
{formatProgress(latestResults.progress_8_score)}
|
||
</div>
|
||
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
||
<div className={styles.heroStatHint}>
|
||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||
</div>
|
||
) : (
|
||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults.english_maths_strong_pass_pct != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
English & Maths Grade 5+
|
||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||
<DeltaChip
|
||
value={latestResults.english_maths_strong_pass_pct}
|
||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults.english_maths_standard_pass_pct != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
English & Maths Grade 4+
|
||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||
<DeltaChip
|
||
value={latestResults.english_maths_standard_pass_pct}
|
||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||
"school vs national", so it's dropped for special schools where
|
||
that comparison isn't meaningful. */}
|
||
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||
<div className={styles.att8Viz}>
|
||
<div className={styles.att8VizLabel}>Attainment 8: school vs national</div>
|
||
<div className={styles.att8VizTrack}>
|
||
<div
|
||
className={styles.att8VizFill}
|
||
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
||
/>
|
||
{secondaryAvg.attainment_8_score != null && (
|
||
<div
|
||
className={styles.att8VizNatLine}
|
||
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
||
>
|
||
<div className={styles.att8VizNatPill}>
|
||
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className={styles.att8VizTicks}>
|
||
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress 8 number line with CI */}
|
||
{latestResults.progress_8_score != null && !p8Suspended && (
|
||
<div className={styles.p8Viz}>
|
||
<div className={styles.p8VizLabel}>Progress 8: relative to national baseline (0)</div>
|
||
{(() => {
|
||
const p8 = latestResults.progress_8_score!;
|
||
const lo = latestResults.progress_8_lower_ci ?? p8;
|
||
const hi = latestResults.progress_8_upper_ci ?? p8;
|
||
const range = 6; // −3 to +3
|
||
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
||
return (
|
||
<div className={styles.p8VizTrack}>
|
||
{/* CI band */}
|
||
<div
|
||
className={styles.p8VizCi}
|
||
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
||
/>
|
||
{/* Zero line */}
|
||
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
||
{/* Score dot */}
|
||
<div
|
||
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
||
style={{ left: toX(p8) }}
|
||
/>
|
||
</div>
|
||
);
|
||
})()}
|
||
<div className={styles.p8VizTicks}>
|
||
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress 8 component breakdown */}
|
||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||
<>
|
||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||
<div className={styles.metricTable}>
|
||
{[
|
||
{ label: 'English', val: latestResults.progress_8_english },
|
||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||
].filter(r => r.val != null).map(({ label, val }) => (
|
||
<div key={label} className={styles.metricRow}>
|
||
<span className={styles.metricName}>{label}</span>
|
||
<span className={`${styles.metricValue} ${progressClass(val)}`}>
|
||
{formatProgress(val!)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* EBacc */}
|
||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||
<>
|
||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||
English Baccalaureate (EBacc)
|
||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||
</h3>
|
||
<div className={styles.metricTable}>
|
||
{latestResults.ebacc_entry_pct != null && (
|
||
<div className={styles.metricRow}>
|
||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||
</div>
|
||
)}
|
||
{latestResults.ebacc_standard_pass_pct != null && (
|
||
<div className={styles.metricRow}>
|
||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||
</div>
|
||
)}
|
||
{latestResults.ebacc_strong_pass_pct != null && (
|
||
<div className={styles.metricRow}>
|
||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||
</div>
|
||
)}
|
||
{latestResults.ebacc_avg_score != null && (
|
||
<div className={styles.metricRow}>
|
||
<span className={styles.metricName}>EBacc average point score</span>
|
||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
</section>
|
||
);
|
||
}
|