perf(detail): render school detail sections on the server #84
@@ -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