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:
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Maths Grade 4+</th>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
|
<>
|
||||||
|
<th>Attainment 8</th>
|
||||||
|
<th>Progress 8</th>
|
||||||
|
<th>English & Maths Grade 4+</th>
|
||||||
|
<th>English & Maths Grade 5+</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th>Reading, Writing & 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 & 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' 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user