refactor(detail): extract sections as server components

Moves ~1,300 lines of section markup out of the two client views into
components/school/, mirroring the components/compare/ layout. Twelve section
components plus shared primitives, all server components. The only client
file is AdmissionsViewToggle, which owns the hidden/aria-pressed state and
receives both views as server-rendered children.

JSX was extracted mechanically rather than retyped, so the markup the CSS
modules depend on is verbatim.

Sharing follows measured similarity, not assumption:
- Finances (91%) shared. The secondary premises-cost card is gated behind a
  prop so primary pages are unchanged; enabling it is a one-line follow-up.
- Ofsted (80%) shared, but behind a variant prop. The headline similarity hid
  a real fork: on a school with no overall grade the primary page shows a
  "Not rated" badge while the secondary shows a four-area OEIF panel, and the
  disclaimer copy differs. Both preserved exactly; reconciling them is a
  human decision, not a side effect of a move.
- Admissions (14%) and History (40%) kept separate.

Not yet wired up -- the old views still render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-08-02 21:33:28 +01:00
co-authored by Claude Opus 5
parent 2f786787b9
commit a7f6ff4035
14 changed files with 1645 additions and 0 deletions
@@ -0,0 +1,137 @@
/**
* AdmissionsSection — primary detail pages.
*
* Not shared with the secondary page: the two versions were only 14% similar
* (this one carries the year/trend toggle and the offer-rate chart; the
* secondary one is a much simpler panel). See SecondaryAdmissionsSection.
*
* Server component. The year/trend toggle is delegated to the small
* AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript.
*/
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
const AdmissionsTrendChart = dynamic(() => import('../AdmissionsTrendChart'), { ssr: false });
export function AdmissionsSection({
admissions,
admissionsHistory,
isAllThrough,
}: {
admissions: SchoolAdmissions;
admissionsHistory: SchoolAdmissions[];
isAllThrough: boolean;
}) {
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions);
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
) : null;
const yearView = (
<>
<dl className={styles.admissionsTiles}>
{admissions.places_offered != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
<>
{admissions.first_preference_offers}
<span className={styles.admissionsTileSub}>
of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)}
</span>
</>
) : (
formatPercentage(admissions.first_preference_offer_pct)
)}
</dd>
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div>
)}
</dl>
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
</>
);
const trendView = (
<>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} />
<p className={styles.admissionsTrendSummary}>
This year ({formatAcademicYear(admissions.year)}),{' '}
{admissions.first_preference_applications != null && (
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
)}
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
{admissions.total_applications != null && `${admissions.total_applications.toLocaleString()} applications in total`}.
</p>
</>
);
return (
<Section id="admissions">
{showAdmissionsTrend ? (
<AdmissionsViewToggle
title={title}
subtitle={subtitle}
trendLabel={`${admissionsHistory.length}-year trend`}
yearView={yearView}
trendView={trendView}
/>
) : (
/* No trend data — render statically, with no client component at all. */
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
</div>
{subtitle}
<div className={styles.admissionsViewport}>
<div className={styles.admissionsViewYear}>{yearView}</div>
</div>
</>
)}
</Section>
);
}
@@ -0,0 +1,58 @@
'use client';
import { useState, type ReactNode } from 'react';
import styles from './schoolSections.module.css';
/**
* The only interactive part of the primary admissions section, and the only
* client component in components/school/.
*
* Both views are always present in the DOM and visibility is toggled with the
* `hidden` attribute — matching the previous behaviour exactly — so the
* server-rendered markup passed in as yearView/trendView never ships as client
* JavaScript.
*
* It spans the header and the viewport because the segmented control sits
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
* below it; wrapping only one would change the DOM the CSS depends on.
*/
export function AdmissionsViewToggle({
title,
subtitle,
trendLabel,
yearView,
trendView,
}: {
title: ReactNode;
subtitle: ReactNode;
trendLabel: string;
yearView: ReactNode;
trendView: ReactNode;
}) {
const [view, setView] = useState<'year' | 'trend'>('year');
return (
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
This year
</button>
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
{trendLabel}
</button>
</div>
</div>
{subtitle}
<div className={styles.admissionsViewport}>
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
{yearView}
</div>
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
{trendView}
</div>
</div>
</>
);
}
@@ -0,0 +1,57 @@
/**
* FinancesSection — shared between the primary and secondary detail pages
* (the two versions were 91% identical).
*
* Server component.
*/
import type { SchoolFinance } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
export function FinancesSection({
finance,
showPremises = false,
}: {
finance: SchoolFinance;
/**
* The secondary page shows a premises-cost card the primary page never had.
* Gated rather than enabled everywhere so this refactor makes no visible
* change; enabling it for primary is a one-line follow-up.
*/
showPremises?: boolean;
}) {
return (
<Section id="finances">
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
{showPremises && finance.premises_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on premises</div>
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</Section>
);
}
@@ -0,0 +1,249 @@
/**
* 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&apos;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 &amp; 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 &amp; 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 (080 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>
);
}
@@ -0,0 +1,156 @@
/**
* HistorySection — results over time (chart plus historical table).
* Primary pages; the secondary equivalent is SecondaryHistorySection (the two
* were only 40% similar). Server component.
*/
import dynamic from 'next/dynamic';
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
export function HistorySection({
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
isAllThrough, isPrimary, isSecondary, hasKS2Results, hasKS4Results,
suppressKs2Comparison, suppressKs4Comparison,
}: {
yearlyData: SchoolResult[];
schoolInfo: School;
nationalAvg: NationalAverages | null;
primaryAvg: Record<string, number>;
secondaryAvg: Record<string, number>;
isAllThrough: boolean;
isPrimary: boolean;
isSecondary: boolean;
hasKS2Results: boolean;
hasKS4Results: boolean;
suppressKs2Comparison: boolean;
suppressKs4Comparison: boolean;
}) {
return (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2>
{isAllThrough ? (
// All-through: KS2 and KS4 trends are on different scales and have
// different gap stories, so render them as two stacked charts
// rather than crowding 8+ series onto one axis.
<>
{hasKS2Results && (
<>
<h3 className={styles.subSectionTitle}>Primary KS2 SATs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
nationalByYear={suppressKs2Comparison ? undefined : 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={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
</>
) : (
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
/>
</div>
)}
{yearlyData.length > 1 && (
<details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
{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>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
<th>English &amp; Maths Grade 5+</th>
</>
) : (
<>
<th>Reading, Writing &amp; Maths (expected %)</th>
<th>Exceeding expected (%)</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
</>
)}
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{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.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>
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
</>
) : (
<>
<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.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</section>
);
}
@@ -0,0 +1,102 @@
/**
* InclusionSection — pupil characteristics and gender split. Primary pages.
* Server component.
*/
import type { SchoolCensus, SchoolResult } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { DeltaChip } from '../DeltaChip';
import { Section, sectionStyles as styles } from './sectionShared';
export function InclusionSection({
latestResults, census, hasGenderSplit, primaryAvg,
}: {
latestResults: SchoolResult | null;
census: SchoolCensus | null;
hasGenderSplit: boolean;
primaryAvg: Record<string, number>;
}) {
return (
<section id="inclusion" className={styles.card}>
<h2 className={styles.sectionTitle}>Pupils &amp; Inclusion</h2>
<div className={styles.heroStatGrid}>
{latestResults?.disadvantaged_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.disadvantaged_pct)}
{primaryAvg.disadvantaged_pct != null && (
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
)}
</div>
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
</div>
)}
{latestResults?.eal_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English as an additional language
<MetricTooltip metricKey="eal_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.eal_pct)}
{primaryAvg.eal_pct != null && (
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
)}
</div>
{primaryAvg.eal_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.sen_support_pct)}
{primaryAvg.sen_support_pct != null && (
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
)}
</div>
{primaryAvg.sen_support_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)}
</div>
)}
{hasGenderSplit && (() => {
const female = census!.female_pupils!;
const male = census!.male_pupils!;
const girlsPct = Math.round((female / (female + male)) * 100);
const boysPct = 100 - girlsPct;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Boys and girls</div>
<div className={styles.genderSplitValue}>
<span className={styles.genderSplitGirls}>{girlsPct}%</span>
<span className={styles.genderSplitLabel}>girls</span>
<span className={styles.genderSplitSep}>·</span>
<span className={styles.genderSplitBoys}>{boysPct}%</span>
<span className={styles.genderSplitLabel}>boys</span>
</div>
<div
className={styles.genderBar}
role="img"
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
>
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
</div>
<div className={styles.heroStatHint}>
{female.toLocaleString()} girls, {male.toLocaleString()} boys
</div>
</div>
);
})()}
</div>
</section>
);
}
@@ -0,0 +1,41 @@
/**
* LocalAreaSection — IDACI deprivation decile. Primary pages only.
*
* Server component.
*/
import type { SchoolDeprivation } from '@/lib/types';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
// Moved with this section from SchoolDetailView, its only consumer.
function deprivationDesc(decile: number) {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
export function LocalAreaSection({ deprivation }: { deprivation: SchoolDeprivation }) {
return (
<Section id="local-area">
<h2 className={styles.sectionTitle}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h2>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</Section>
);
}
@@ -0,0 +1,164 @@
/**
* OfstedSection — shared between the primary and secondary detail pages.
*
* The two versions were ~80% identical, but that figure masked a real fork in
* the no-overall-grade case: the primary page shows a "Not rated" badge, while
* the secondary page shows a four-area OEIF panel. The disclaimer copy also
* differs slightly. Both are preserved exactly via the `variant` prop rather
* than reconciled, because this refactor must not change either page. Merging
* them is a follow-up decision for a human, not a side effect of a move.
*
* Server component.
*/
import type { OfstedInspection } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
const RC_LABELS: Record<number, string> = {
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
};
const RC_CATEGORIES = [
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
{ key: 'rc_achievement' as const, label: 'Achievement' },
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
{ key: 'rc_early_years' as const, label: 'Early Years' },
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
];
export interface OfstedSectionProps {
ofsted: OfstedInspection;
urn: number;
isReportCard: boolean;
ofstedInspectedDate: string | null;
oeifAllSameGrade: boolean;
oeifAreas: { label: string; value: number }[];
variant?: 'primary' | 'secondary';
}
export function OfstedSection({
ofsted, urn, isReportCard, ofstedInspectedDate,
oeifAllSameGrade, oeifAreas, variant = 'primary',
}: OfstedSectionProps) {
const isSecondary = variant === 'secondary';
return (
<Section id="ofsted">
<h2 className={styles.sectionTitle}>
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && (
<span className={styles.ofstedDate}>
{isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${urn}`}
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Ofsted reports
</a>
</h2>
{isReportCard ? (
/* ── New Report Card layout ── */
<>
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</div>
</div>
)}
{RC_CATEGORIES.map(({ key, label }) => {
const value = ofsted[key] as number | null;
return value != null ? (
<div key={key} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
{RC_LABELS[value]}
</div>
</div>
) : null;
})}
</div>
</>
) : (!isSecondary || ofsted.overall_effectiveness) ? (
/* ── Old OEIF layout ── */
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: isSecondary
? 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
</p>
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
)}
</>
) : (
/* ── Secondary only: inspected since Sept 2024, no overall grade ── */
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
</div>
))}
</div>
</>
)}
</Section>
);
}
@@ -0,0 +1,304 @@
/**
* ResultsSection — KS2 SATs (and, for all-through schools, the KS4 block).
* Primary and all-through pages. Server component.
*/
import dynamic from 'next/dynamic';
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';
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
export function ResultsSection({
latestResults, schoolInfo, primaryAvg, secondaryAvg,
isAllThrough, isSecondary, isSpecial, hasKS2Results, hasKS4Results,
ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
}: {
latestResults: SchoolResult;
schoolInfo: School;
primaryAvg: Record<string, number>;
secondaryAvg: Record<string, number>;
isAllThrough: boolean;
isSecondary: boolean;
isSpecial: boolean;
hasKS2Results: boolean;
hasKS4Results: boolean;
ks2Placeholder: boolean;
suppressKs2Comparison: boolean;
suppressKs4Comparison: boolean;
}) {
return (
<section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}>
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
{isSpecial
? (isSecondary
? 'GCSE results for Year 11 pupils.'
: 'End-of-primary-school tests taken by Year 6 pupils.')
: isAllThrough
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. 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>
{/* Explains up front why the England comparison is dropped below, so
a 0% headline never reads as a failing grade against a benchmark
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
<SpecialSchoolNote school={schoolInfo} />
{/* ── Primary / KS2 content ── */}
{hasKS2Results && (
<>
{isAllThrough && (
<h3 className={styles.subSectionTitle}>Primary KS2 SATs (Year 6)</h3>
)}
<div className={styles.heroStatGrid}>
{latestResults.rwm_expected_pct !== null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Reading, Writing &amp; Maths combined
<MetricTooltip metricKey="rwm_expected_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<DeltaChip
value={latestResults.rwm_expected_pct}
baseline={primaryAvg.rwm_expected_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.rwm_high_pct !== null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Exceeding expected level (Reading, Writing &amp; Maths)
<MetricTooltip metricKey="rwm_high_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<DeltaChip
value={latestResults.rwm_high_pct}
baseline={primaryAvg.rwm_high_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{!suppressKs2Comparison &&
latestResults.rwm_expected_pct != null &&
latestResults.reading_expected_pct != null &&
latestResults.writing_expected_pct != null &&
latestResults.maths_expected_pct != null && (
<div className={styles.rwmBridge}>
<span className={styles.rwmBridgeIcon} aria-hidden="true">?</span>
<div className={styles.rwmBridgeBody}>
<div className={styles.rwmBridgeText}>
Why is combined lower? A pupil is only counted if they met the bar in{' '}
<strong>all three</strong> subjects. Some passed reading but not writing; some passed writing but not maths.
</div>
<div className={styles.rwmBridgeMath}>
<span>Reading <strong>{latestResults.reading_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}>·</span>
<span>Writing <strong>{latestResults.writing_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}>·</span>
<span>Maths <strong>{latestResults.maths_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}></span>
<span>All three <strong>{latestResults.rwm_expected_pct.toFixed(0)}%</strong></span>
</div>
</div>
</div>
)}
{/* All-zero placeholder rows (special / suppressed) would render as
three empty bars against the national markers — misleading, so
skip the chart. For a special school with some non-zero
subjects, keep the bars but drop the national markers. */}
{!ks2Placeholder && (
<SatsChart
subjects={[
{
name: 'Reading',
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
// Writing's higher level is teacher-assessed "greater depth".
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
},
{
name: 'Maths',
expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
},
]}
/>
)}
{/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
<div className={styles.progressScoresRow}>
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
<div className={styles.progressScoresGrid}>
{latestResults.reading_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Reading</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
{formatProgress(latestResults.reading_progress)}
</span>
</div>
)}
{latestResults.writing_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Writing</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
{formatProgress(latestResults.writing_progress)}
</span>
</div>
)}
{latestResults.maths_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Maths</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
{formatProgress(latestResults.maths_progress)}
</span>
</div>
)}
</div>
</div>
)}
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
<p className={styles.progressNote}>
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
</p>
)}
</>
)}
{/* ── Secondary / KS4 content ── */}
{hasKS4Results && (
<>
{isAllThrough && (
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs (Year 11)</h3>
)}
<div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Attainment 8
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Progress 8
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
<div className={styles.metricHint}>0 = national average</div>
</div>
)}
{latestResults.english_maths_standard_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</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+
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
</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+
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
</div>
</>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,51 @@
/**
* SchoolLifeSection — absence figures. Primary pages only; the secondary page
* carries the equivalent content inside WellbeingSection.
*
* Server component.
*/
import type { AbsenceData } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
export function SchoolLifeSection({
absenceData,
primaryAvg,
}: {
absenceData: AbsenceData | null;
primaryAvg: Record<string, number>;
}) {
return (
<Section id="school-life">
<h2 className={styles.sectionTitle}>School Life</h2>
<div className={styles.metricsGrid}>
{absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Days missed (overall absence)
<MetricTooltip metricKey="overall_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
{primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)}
</div>
)}
{absenceData?.persistent_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Regularly missing school
<MetricTooltip metricKey="persistent_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
{primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
</Section>
);
}
@@ -0,0 +1,87 @@
/**
* SecondaryAdmissionsSection — secondary pages.
*
* Not shared with the primary AdmissionsSection: the two were only 14%
* similar. This one has no year/trend toggle, so it ships no client
* JavaScript at all. Server component.
*/
import type { School, SchoolAdmissions } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
export function SecondaryAdmissionsSection({
admissions, schoolInfo, hasSixthForm,
}: {
admissions: SchoolAdmissions;
schoolInfo: School;
hasSixthForm: boolean;
}) {
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
})();
return (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>Admissions</h2>
{admissionsTag && (
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
<strong>{admissionsTag}</strong>{' '}
{admissionsTag === 'Selective'
? '— Entry to this school is by selective examination (e.g. 11+).'
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
: '✓ Places were available last year'}
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
</div>
)}
</section>
);
}
@@ -0,0 +1,74 @@
/**
* SecondaryHistorySection — results over time. Secondary pages.
* Server component.
*/
import dynamic from 'next/dynamic';
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
export function SecondaryHistorySection({
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
}: {
yearlyData: SchoolResult[];
schoolInfo: School;
nationalAvg: NationalAverages | null;
secondaryAvg: Record<string, number>;
suppressComparison: boolean;
}) {
// National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
return (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Historical Results</h2>
{yearlyData.length > 0 && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
<details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>Eng &amp; Maths 4+</th>
<th>EBacc entry %</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</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>
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
);
}
@@ -0,0 +1,122 @@
/**
* WellbeingSection — SEN, gender split and local-area deprivation.
* Secondary pages. Server component.
*/
import type { School, SchoolCensus, SchoolResult, SchoolDeprivation } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
// Moved with this section from SecondarySchoolDetailView, its only consumer.
function deprivationDesc(decile: number) {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
export function WellbeingSection({
latestResults, census, schoolInfo, deprivation, hasDeprivation,
}: {
latestResults: SchoolResult | null;
census: SchoolCensus | null;
schoolInfo: School;
deprivation: SchoolDeprivation | null;
hasDeprivation: boolean;
}) {
return (
<section id="wellbeing" className={styles.card}>
<h2 className={styles.sectionTitle}>Wellbeing &amp; Context</h2>
{/* SEN */}
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
<>
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
<div className={styles.heroStatGrid}>
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.heroStatHint}>Without an EHCP</div>
</div>
)}
{latestResults?.sen_ehcp_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils with EHCP
<MetricTooltip metricKey="sen_ehcp_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
</div>
)}
{(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null;
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
const hasSplit = isMixed && female != null && male != null && female + male > 0;
const sum = hasSplit ? female! + male! : 0;
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
const boysPct = hasSplit ? 100 - girlsPct : 0;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Total pupils</div>
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
{hasSplit && (
<>
<div
className={styles.genderBar}
role="img"
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
>
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
</div>
<div className={styles.genderSplitHint}>
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
<span className={styles.genderSplitSep}> · </span>
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
</div>
</>
)}
{schoolInfo.capacity != null && !hasSplit && (
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
)}
</div>
);
})()}
</div>
</>
)}
{/* Deprivation */}
{hasDeprivation && deprivation && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h3>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</>
)}
</section>
);
}
@@ -0,0 +1,43 @@
/**
* Shared primitives for the school detail sections, mirroring
* components/compare/sectionShared.tsx.
*
* Deliberately minimal: the sections were extracted as verbatim moves from the
* two detail views, so wrapping their markup in heavyweight primitives would
* risk changing the DOM the CSS modules depend on. This provides only the
* outer <section> shell every section shares, plus the stylesheet.
*
* All section components are SERVER components — no 'use client' anywhere in
* this directory except AdmissionsViewToggle.
*/
import type { ReactNode } from 'react';
import styles from './schoolSections.module.css';
export const sectionStyles = styles;
/** Tone class for a progress score: positive, negative, or neutral. */
export function progressClass(val: number | null | undefined): string {
if (val == null) return '';
if (val > 0) return styles.progressPositive;
if (val < 0) return styles.progressNegative;
return '';
}
/**
* The section shell. `id` must match the id buildNavItems emits, because the
* sticky nav's scroll-spy locates sections with document.getElementById.
*/
export function Section({
id,
children,
}: {
id: string;
children: ReactNode;
}) {
return (
<section id={id} className={styles.card}>
{children}
</section>
);
}