fix(detail): render both phases for all-through schools (Batch E)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 57s

Batch E of applying the compare-screen learnings to the school detail page —
all-through handling (point 11).

An all-through school carries both KS2 and KS4 figures in the same yearly
rows, but SchoolDetailView flipped it to isSecondary and rendered GCSE-only,
hiding the entire primary phase (SATs, phonics, KS2 trend). The Results
snapshot already gated its KS2/KS4 blocks purely on data availability, so both
already appeared there — but the section title, the trend chart, phonics, the
nav label and the history table all still assumed a single phase.

- Add an explicit `isAllThrough` flag (+ `showPrimaryContent = isPrimary ||
  isAllThrough`); pure-secondary behaviour is unchanged.
- Hero: an "All-through (primary & secondary)" meta chip for all-ages framing.
- Results section: title "SATs & GCSE Results", a combined subtitle, and
  "Primary — KS2 SATs (Year 6)" / "Secondary — GCSEs (Year 11)" sub-headings
  separating the two blocks.
- Results Over Time: render two stacked PerformanceCharts (KS2 SATs + GCSEs)
  rather than crowding both stages' series — on different scales with
  different gap stories — onto one axis. Each gets its correct England overlay.
- Phonics section + nav item now show for all-through (primary-stage metric).
- History table: an all-through column set covering both phases (RWM, Exceeding,
  Attainment 8, Progress 8, Eng & Maths 4+).
- Nav "Results" label for all-through instead of "GCSEs".

e2e: a new journey asserts an all-through school (Hessle, 137306) shows both
the KS2 and KS4 results and the All-through label, with a data-driven
precondition skip if staging data drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-20 12:53:07 +01:00
co-authored by Claude Opus 4.8
parent 9c93c3d9c2
commit f6bb037c47
2 changed files with 122 additions and 22 deletions
+31
View File
@@ -148,6 +148,37 @@ test('a report-card school shows its report card, dated to the report-card inspe
} }
}); });
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
// The detail view used to flip them to isSecondary and render GCSE-only,
// hiding the primary phase. It now renders both phases and labels the school
// "All-through".
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
const res = await page.request.get(`/api/schools/${AT_URN}`);
expect(res.ok()).toBeTruthy();
const detail = await res.json();
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
detail.yearly_data ?? [];
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
test.skip(
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
);
await page.goto(`/school/${AT_URN}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Labelled as all-through in the hero meta.
await expect(page.getByText(/All-through/i).first()).toBeVisible();
// The combined results section carries both phases.
const results = page.locator('#results');
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
await expect(results.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2
await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4
});
test('school with no performance data still gets a working detail page', async ({ page }) => { test('school with no performance data still gets a working detail page', async ({ page }) => {
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form // Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON // centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
+91 -22
View File
@@ -158,10 +158,16 @@ export function SchoolDetailView({
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection // Phase detection. All-through schools cover BOTH key stages, so they are
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
const phase = schoolInfo.phase ?? ''; const phase = schoolInfo.phase ?? '';
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through'; const isAllThrough = phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
const isPrimary = !isSecondary; const isPrimary = !isSecondary;
// Primary-stage content shows for pure-primary AND all-through schools.
const showPrimaryContent = isPrimary || isAllThrough;
// National averages (fetched dynamically so they stay current) // National averages (fetched dynamically so they stay current)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null); const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -233,11 +239,11 @@ export function SchoolDetailView({
// after the recognised Ofsted badge; low-demand context sections stay last. // after the recognised Ofsted badge; low-demand context sections stay last.
const navItems: { id: string; label: string }[] = []; const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' }); if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -324,6 +330,9 @@ export function SchoolDetailView({
{schoolInfo.school_type && ( {schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span> <span className={styles.metaItem}>{schoolInfo.school_type}</span>
)} )}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span> <span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)} )}
@@ -612,17 +621,22 @@ export function SchoolDetailView({
{hasAnyResults && latestResults && ( {hasAnyResults && latestResults && (
<section id="results" className={styles.card}> <section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)}) {isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2> </h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
{isSecondary {isAllThrough
? 'GCSE results for Year 11 pupils. England averages shown for comparison.' ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'} : isSecondary
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
</p> </p>
{/* ── Primary / KS2 content ── */} {/* ── Primary / KS2 content ── */}
{hasKS2Results && ( {hasKS2Results && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle}>Primary KS2 SATs (Year 6)</h3>
)}
<div className={styles.heroStatGrid}> <div className={styles.heroStatGrid}>
{latestResults.rwm_expected_pct !== null && ( {latestResults.rwm_expected_pct !== null && (
<div className={styles.heroStatCard}> <div className={styles.heroStatCard}>
@@ -761,6 +775,9 @@ export function SchoolDetailView({
{/* ── Secondary / KS4 content ── */} {/* ── Secondary / KS4 content ── */}
{hasKS4Results && ( {hasKS4Results && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs (Year 11)</h3>
)}
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && ( {latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
@@ -1046,16 +1063,52 @@ export function SchoolDetailView({
{yearlyData.length > 0 && ( {yearlyData.length > 0 && (
<section id="history" className={styles.card}> <section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2> <h2 className={styles.sectionTitle}>Results Over Time</h2>
<div className={styles.chartContainer}> {isAllThrough ? (
<PerformanceChart // All-through: KS2 and KS4 trends are on different scales and have
data={yearlyData} // different gap stories, so render them as two stacked charts
schoolName={schoolInfo.school_name} // rather than crowding 8+ series onto one axis.
isSecondary={isSecondary} <>
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null} {hasKS2Results && (
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null} <>
nationalByYear={nationalAvg?.by_year} <h3 className={styles.subSectionTitle}>Primary KS2 SATs</h3>
/> <div className={styles.chartContainer}>
</div> <PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
)}
{hasKS4Results && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
)}
</>
) : (
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
)}
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
<details className={styles.historyDisclosure}> <details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary> <summary className={styles.historyToggle}>View raw year-by-year data</summary>
@@ -1064,7 +1117,15 @@ export function SchoolDetailView({
<thead> <thead>
<tr> <tr>
<th>Year</th> <th>Year</th>
{isSecondary ? ( {isAllThrough ? (
<>
<th>RWM (expected %)</th>
<th>Exceeding (%)</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
</>
) : isSecondary ? (
<> <>
<th>Attainment 8</th> <th>Attainment 8</th>
<th>Progress 8</th> <th>Progress 8</th>
@@ -1086,7 +1147,15 @@ export function SchoolDetailView({
{yearlyData.map((result) => ( {yearlyData.map((result) => (
<tr key={result.year}> <tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td> <td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isSecondary ? ( {isAllThrough ? (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
</>
) : isSecondary ? (
<> <>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td> <td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td> <td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
@@ -1111,8 +1180,8 @@ export function SchoolDetailView({
)} )}
</section> </section>
)} )}
{/* Year 1 Phonics — primary only */} {/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
{hasPhonics && isPrimary && phonics && ( {hasPhonics && showPrimaryContent && phonics && (
<section id="phonics" className={styles.card}> <section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2> <h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>