diff --git a/nextjs-app/components/school/AdmissionsSection.tsx b/nextjs-app/components/school/AdmissionsSection.tsx new file mode 100644 index 0000000..95f90dd --- /dev/null +++ b/nextjs-app/components/school/AdmissionsSection.tsx @@ -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 ? ( +

+ These figures are for {admissions.school_phase.toLowerCase()} entry + {/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}. +

+ ) : null; + + const yearView = ( + <> +
+ {admissions.places_offered != null && ( +
+
{admissions.places_offered}
+
Places offered
+
+ )} + {admissions.first_preference_applications != null && ( +
+
{admissions.first_preference_applications}
+
Wanted it first
+
+ )} + {admissions.first_preference_offer_pct != null && ( +
+
+ {admissions.first_preference_offers != null && admissions.first_preference_applications != null ? ( + <> + {admissions.first_preference_offers} + + of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)} + + + ) : ( + formatPercentage(admissions.first_preference_offer_pct) + )} +
+
Got their first choice
+
+ )} + {admissions.total_applications != null && ( +
+
{admissions.total_applications.toLocaleString()}
+
Applied in total
+
+ )} +
+ {admissionsSummary.chip && ( +

{admissionsSummary.chip.text}

+ )} + + ); + + const trendView = ( + <> +
First-choice offer rate
+ +

+ This year ({formatAcademicYear(admissions.year)}),{' '} + {admissions.first_preference_applications != null && ( + <>{admissions.first_preference_applications} families put it first for + )} + {admissions.places_offered != null && <>{admissions.places_offered} places} + {admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}. +

+ + ); + + return ( +
+ {showAdmissionsTrend ? ( + + ) : ( + /* No trend data — render statically, with no client component at all. */ + <> +
+

{title}

+
+ {subtitle} +
+
{yearView}
+
+ + )} +
+ ); +} diff --git a/nextjs-app/components/school/AdmissionsViewToggle.tsx b/nextjs-app/components/school/AdmissionsViewToggle.tsx new file mode 100644 index 0000000..000920f --- /dev/null +++ b/nextjs-app/components/school/AdmissionsViewToggle.tsx @@ -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

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 ( + <> +
+

{title}

+
+ + +
+
+ {subtitle} +
+ + +
+ + ); +} diff --git a/nextjs-app/components/school/FinancesSection.tsx b/nextjs-app/components/school/FinancesSection.tsx new file mode 100644 index 0000000..58df160 --- /dev/null +++ b/nextjs-app/components/school/FinancesSection.tsx @@ -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 ( +
+

School Finances ({formatAcademicYear(finance.year)})

+

+ Per-pupil spending shows how much the school has to spend on each child's education. +

+
+
+
Total spend per pupil per year
+
£{Math.round(finance.per_pupil_spend!).toLocaleString()}
+
How much the school has to spend on each pupil annually
+
+ {finance.teacher_cost_pct != null && ( +
+
Share of budget spent on teachers
+
{finance.teacher_cost_pct.toFixed(1)}%
+
+ )} + {finance.staff_cost_pct != null && ( +
+
Share of budget spent on all staff
+
{finance.staff_cost_pct.toFixed(1)}%
+
+ )} + {showPremises && finance.premises_cost_pct != null && ( +
+
Share of budget spent on premises
+
{finance.premises_cost_pct.toFixed(1)}%
+
+ )} +
+
+ ); +} diff --git a/nextjs-app/components/school/GcseSection.tsx b/nextjs-app/components/school/GcseSection.tsx new file mode 100644 index 0000000..41be86c --- /dev/null +++ b/nextjs-app/components/school/GcseSection.tsx @@ -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; + p8Suspended: boolean; + suppressComparison: boolean; +}) { + return ( +
+

+ GCSE Results ({formatAcademicYear(latestResults.year)}) +

+

+ GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'} +

+ + + + {p8Suspended && ( +
+ 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. +
+ )} + + {/* Hero stat cards — top GCSE metrics */} +
+ {latestResults.attainment_8_score != null && ( +
+
+ Attainment 8 score + +
+
+ {latestResults.attainment_8_score.toFixed(1)} + {!suppressComparison && secondaryAvg.attainment_8_score != null && ( + + )} +
+ {!suppressComparison && secondaryAvg.attainment_8_score != null && ( +
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
+ )} +
+ )} + {latestResults.progress_8_score != null && ( +
+
+ Progress 8 score + +
+
+ {formatProgress(latestResults.progress_8_score)} +
+ {(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? ( +
+ CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)} +
+ ) : ( +
National baseline: 0.0
+ )} +
+ )} + {latestResults.english_maths_strong_pass_pct != null && ( +
+
+ English & Maths Grade 5+ + +
+
+ {formatPercentage(latestResults.english_maths_strong_pass_pct)} + {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( + + )} +
+ {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( +
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
+ )} +
+ )} + {latestResults.english_maths_standard_pass_pct != null && ( +
+
+ English & Maths Grade 4+ + +
+
+ {formatPercentage(latestResults.english_maths_standard_pass_pct)} + {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( + + )} +
+ {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( +
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
+ )} +
+ )} +
+ + {/* 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 && ( +
+
Attainment 8 — school vs national
+
+
+ {secondaryAvg.attainment_8_score != null && ( +
+
+ Nat avg {secondaryAvg.attainment_8_score.toFixed(1)} +
+
+ )} +
+
+ 020406080 +
+
+ )} + + {/* Progress 8 number line with CI */} + {latestResults.progress_8_score != null && !p8Suspended && ( +
+
Progress 8 — relative to national baseline (0)
+ {(() => { + 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 ( +
+ {/* CI band */} +
+ {/* Zero line */} +
+ {/* Score dot */} +
+
+ ); + })()} +
+ −3−2−10+1+2+3 +
+
+ )} + + {/* Progress 8 component breakdown */} + {(latestResults.progress_8_english != null || latestResults.progress_8_maths != null || + latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && ( + <> +

Attainment 8 Components (Progress 8 contribution)

+
+ {[ + { 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 }) => ( +
+ {label} + + {formatProgress(val!)} + +
+ ))} +
+ + )} + + {/* EBacc */} + {(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && ( + <> +

+ English Baccalaureate (EBacc) + +

+
+ {latestResults.ebacc_entry_pct != null && ( +
+ Pupils entered for EBacc + {formatPercentage(latestResults.ebacc_entry_pct)} +
+ )} + {latestResults.ebacc_standard_pass_pct != null && ( +
+ EBacc Grade 4+ + {formatPercentage(latestResults.ebacc_standard_pass_pct)} +
+ )} + {latestResults.ebacc_strong_pass_pct != null && ( +
+ EBacc Grade 5+ + {formatPercentage(latestResults.ebacc_strong_pass_pct)} +
+ )} + {latestResults.ebacc_avg_score != null && ( +
+ EBacc average point score + {latestResults.ebacc_avg_score.toFixed(2)} +
+ )} +
+ + )} + +
+ ); +} diff --git a/nextjs-app/components/school/HistorySection.tsx b/nextjs-app/components/school/HistorySection.tsx new file mode 100644 index 0000000..01df546 --- /dev/null +++ b/nextjs-app/components/school/HistorySection.tsx @@ -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; + secondaryAvg: Record; + isAllThrough: boolean; + isPrimary: boolean; + isSecondary: boolean; + hasKS2Results: boolean; + hasKS4Results: boolean; + suppressKs2Comparison: boolean; + suppressKs4Comparison: boolean; +}) { + return ( +
+

Results Over Time

+ {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 && ( + <> +

Primary — KS2 SATs

+
+ +
+ + )} + {hasKS4Results && ( + <> +

Secondary — GCSEs

+
+ +
+ + )} + + ) : ( +
+ +
+ )} + {yearlyData.length > 1 && ( +
+ View raw year-by-year data +
+ + + + + {isAllThrough ? ( + <> + + + + + + + ) : isSecondary ? ( + <> + + + + + + ) : ( + <> + + + + + + + )} + + + + {yearlyData.map((result) => ( + + + {isAllThrough ? ( + <> + + + + + + + ) : isSecondary ? ( + <> + + + + + + ) : ( + <> + + + + + + + )} + + ))} + +
YearRWM (expected %)Exceeding (%)Attainment 8Progress 8English & Maths Grade 4+Attainment 8Progress 8English & Maths Grade 4+English & Maths Grade 5+Reading, Writing & Maths (expected %)Exceeding expected (%)Reading ProgressWriting ProgressMaths Progress
{formatAcademicYear(result.year)}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}
+
+
+ )} +
+ ); +} diff --git a/nextjs-app/components/school/InclusionSection.tsx b/nextjs-app/components/school/InclusionSection.tsx new file mode 100644 index 0000000..d3029a5 --- /dev/null +++ b/nextjs-app/components/school/InclusionSection.tsx @@ -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; +}) { + return ( +
+

Pupils & Inclusion

+
+ {latestResults?.disadvantaged_pct != null && ( +
+
Eligible for pupil premium
+
+ {formatPercentage(latestResults.disadvantaged_pct)} + {primaryAvg.disadvantaged_pct != null && ( + + )} +
+
Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}
+
+ )} + {latestResults?.eal_pct != null && ( +
+
+ English as an additional language + +
+
+ {formatPercentage(latestResults.eal_pct)} + {primaryAvg.eal_pct != null && ( + + )} +
+ {primaryAvg.eal_pct != null && ( +
England avg: {primaryAvg.eal_pct.toFixed(0)}%
+ )} +
+ )} + {latestResults?.sen_support_pct != null && ( +
+
+ Pupils receiving SEN support + +
+
+ {formatPercentage(latestResults.sen_support_pct)} + {primaryAvg.sen_support_pct != null && ( + + )} +
+ {primaryAvg.sen_support_pct != null && ( +
England avg: {primaryAvg.sen_support_pct.toFixed(0)}%
+ )} +
+ )} + {hasGenderSplit && (() => { + const female = census!.female_pupils!; + const male = census!.male_pupils!; + const girlsPct = Math.round((female / (female + male)) * 100); + const boysPct = 100 - girlsPct; + return ( +
+
Boys and girls
+
+ {girlsPct}% + girls + · + {boysPct}% + boys +
+
+ + +
+
+ {female.toLocaleString()} girls, {male.toLocaleString()} boys +
+
+ ); + })()} +
+
+ ); +} diff --git a/nextjs-app/components/school/LocalAreaSection.tsx b/nextjs-app/components/school/LocalAreaSection.tsx new file mode 100644 index 0000000..8cfbfe9 --- /dev/null +++ b/nextjs-app/components/school/LocalAreaSection.tsx @@ -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 ( +
+

+ Local Area Context + +

+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ ))} +
+
+ Most deprived + Least deprived +
+

{deprivationDesc(deprivation.idaci_decile!)}

+
+ ); +} diff --git a/nextjs-app/components/school/OfstedSection.tsx b/nextjs-app/components/school/OfstedSection.tsx new file mode 100644 index 0000000..a51cacd --- /dev/null +++ b/nextjs-app/components/school/OfstedSection.tsx @@ -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 = { + 1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate', +}; + +const RC_LABELS: Record = { + 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 ( +
+

+ {isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'} + {ofstedInspectedDate && ( + + {isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} + + )} + + Ofsted reports ↗ + +

+ + {isReportCard ? ( + /* ── New Report Card layout ── */ + <> +

+ From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. +

+
+ {ofsted.rc_safeguarding_met != null && ( +
+
Safeguarding
+
+ {ofsted.rc_safeguarding_met ? 'Met' : 'Not met'} +
+
+ )} + {RC_CATEGORIES.map(({ key, label }) => { + const value = ofsted[key] as number | null; + return value != null ? ( +
+
{label}
+
+ {RC_LABELS[value]} +
+
+ ) : null; + })} +
+ + ) : (!isSecondary || ofsted.overall_effectiveness) ? ( + /* ── Old OEIF layout ── */ + <> +
+ + {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} + + {ofsted.previous_overall != null && + ofsted.previous_overall !== ofsted.overall_effectiveness && ( + + Previously: {OFSTED_LABELS[ofsted.previous_overall]} + + )} +
+

+ {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.'} +

+ {oeifAllSameGrade ? ( +

+ Rated {OFSTED_LABELS[ofsted.overall_effectiveness!]} across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership. +

+ ) : ( +
+ {oeifAreas.map(({ label, value }) => ( +
+
{label}
+
+ {OFSTED_LABELS[value]} +
+
+ ))} +
+ )} + + ) : ( + /* ── Secondary only: inspected since Sept 2024, no overall grade ── */ + <> +

+ From September 2024, Ofsted no longer gives a single overall grade. +

+
+ {[ + { 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 }) => ( +
+
{label}
+
+ {OFSTED_LABELS[value!]} +
+
+ ))} +
+ + )} +
+ ); +} diff --git a/nextjs-app/components/school/ResultsSection.tsx b/nextjs-app/components/school/ResultsSection.tsx new file mode 100644 index 0000000..d002a77 --- /dev/null +++ b/nextjs-app/components/school/ResultsSection.tsx @@ -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; + secondaryAvg: Record; + isAllThrough: boolean; + isSecondary: boolean; + isSpecial: boolean; + hasKS2Results: boolean; + hasKS4Results: boolean; + ks2Placeholder: boolean; + suppressKs2Comparison: boolean; + suppressKs4Comparison: boolean; +}) { + return ( +
+

+ {isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)}) +

+

+ {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.'} +

+ + {/* 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). */} + + + {/* ── Primary / KS2 content ── */} + {hasKS2Results && ( + <> + {isAllThrough && ( +

Primary — KS2 SATs (Year 6)

+ )} +
+ {latestResults.rwm_expected_pct !== null && ( +
+
+ Reading, Writing & Maths combined + +
+
+ {formatPercentage(latestResults.rwm_expected_pct)} + {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( + + )} +
+ {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( +
England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
+ )} +
+ )} + {latestResults.rwm_high_pct !== null && ( +
+
+ Exceeding expected level (Reading, Writing & Maths) + +
+
+ {formatPercentage(latestResults.rwm_high_pct)} + {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( + + )} +
+ {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( +
England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
+ )} +
+ )} +
+ + {!suppressKs2Comparison && + latestResults.rwm_expected_pct != null && + latestResults.reading_expected_pct != null && + latestResults.writing_expected_pct != null && + latestResults.maths_expected_pct != null && ( +
+ +
+
+ Why is combined lower? A pupil is only counted if they met the bar in{' '} + all three subjects. Some passed reading but not writing; some passed writing but not maths. +
+
+ Reading {latestResults.reading_expected_pct.toFixed(0)}% + · + Writing {latestResults.writing_expected_pct.toFixed(0)}% + · + Maths {latestResults.maths_expected_pct.toFixed(0)}% + + All three {latestResults.rwm_expected_pct.toFixed(0)}% +
+
+
+ )} + + {/* 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 && ( + + )} + + {/* Progress scores row */} + {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && ( +
+

Progress Scores

+
+ {latestResults.reading_progress != null && ( +
+ Reading + + {formatProgress(latestResults.reading_progress)} + +
+ )} + {latestResults.writing_progress != null && ( +
+ Writing + + {formatProgress(latestResults.writing_progress)} + +
+ )} + {latestResults.maths_progress != null && ( +
+ Maths + + {formatProgress(latestResults.maths_progress)} + +
+ )} +
+
+ )} + + {(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && ( +

+ Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average. +

+ )} + + )} + + {/* ── Secondary / KS4 content ── */} + {hasKS4Results && ( + <> + {isAllThrough && ( +

Secondary — GCSEs (Year 11)

+ )} +
+ {latestResults.attainment_8_score !== null && ( +
+
+ Attainment 8 + +
+
{latestResults.attainment_8_score.toFixed(1)}
+ {!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && ( +
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
+ )} +
+ )} + {latestResults.progress_8_score !== null && ( +
+
+ Progress 8 + +
+
+ {formatProgress(latestResults.progress_8_score)} +
+
0 = national average
+
+ )} + {latestResults.english_maths_standard_pass_pct !== null && ( +
+
+ English & Maths Grade 4+ + +
+
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
+ {!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && ( +
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
+ )} +
+ )} + {latestResults.english_maths_strong_pass_pct !== null && ( +
+
+ English & Maths Grade 5+ + +
+
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
+ {!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && ( +
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
+ )} +
+ )} +
+ + {/* EBacc */} + {(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && ( + <> +

+ English Baccalaureate (EBacc) + +

+
+ {latestResults.ebacc_entry_pct !== null && ( +
+ Pupils entered for EBacc + {formatPercentage(latestResults.ebacc_entry_pct)} +
+ )} + {latestResults.ebacc_standard_pass_pct !== null && ( +
+ + EBacc Grade 4+ + + + {formatPercentage(latestResults.ebacc_standard_pass_pct)} +
+ )} + {latestResults.ebacc_strong_pass_pct !== null && ( +
+ + EBacc Grade 5+ + + + {formatPercentage(latestResults.ebacc_strong_pass_pct)} +
+ )} +
+ + )} + + )} +
+ ); +} diff --git a/nextjs-app/components/school/SchoolLifeSection.tsx b/nextjs-app/components/school/SchoolLifeSection.tsx new file mode 100644 index 0000000..e13d650 --- /dev/null +++ b/nextjs-app/components/school/SchoolLifeSection.tsx @@ -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; +}) { + return ( +
+

School Life

+
+ {absenceData?.overall_absence_rate != null && ( +
+
+ Days missed (overall absence) + +
+
{formatPercentage(absenceData.overall_absence_rate)}
+ {primaryAvg.overall_absence_pct != null && ( +
England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%
+ )} +
+ )} + {absenceData?.persistent_absence_rate != null && ( +
+
+ Regularly missing school + +
+
{formatPercentage(absenceData.persistent_absence_rate)}
+ {primaryAvg.persistent_absence_pct != null && ( +
England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%
+ )} +
+ )} +
+
+ ); +} diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx new file mode 100644 index 0000000..bd0fbf8 --- /dev/null +++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx @@ -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 ( +
+

Admissions

+ + {admissionsTag && ( +
+ {admissionsTag}{' '} + {admissionsTag === 'Selective' + ? '— Entry to this school is by selective examination (e.g. 11+).' + : `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`} +
+ )} + +
+ {admissions.places_offered != null && ( +
+
Year 7 places offered
+
{admissions.places_offered}
+
+ )} + {admissions.total_applications != null && ( +
+
Total applications
+
{admissions.total_applications.toLocaleString()}
+
+ )} + {admissions.first_preference_applications != null && ( +
+
1st preference applications
+
{admissions.first_preference_applications.toLocaleString()}
+
+ )} + {admissions.first_preference_offer_pct != null && ( +
+
Families who got their first choice
+
{formatPercentage(admissions.first_preference_offer_pct)}
+
+ )} +
+ {admissions.oversubscribed != null && ( +
+ {admissions.oversubscribed + ? '⚠ Applications exceeded places last year' + : '✓ Places were available last year'} +
+ )} + +

+ Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details. +

+ + {hasSixthForm && ( +
+ This school has a sixth form (Post-16 provision). Post-16 destination data coming soon. +
+ )} +
+ ); +} diff --git a/nextjs-app/components/school/SecondaryHistorySection.tsx b/nextjs-app/components/school/SecondaryHistorySection.tsx new file mode 100644 index 0000000..2d73f2a --- /dev/null +++ b/nextjs-app/components/school/SecondaryHistorySection.tsx @@ -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; + suppressComparison: boolean; +}) { + // National Attainment 8 baseline for the "Results Over Time" chart. + const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null; + + return ( +
+

Historical Results

+ {yearlyData.length > 0 && ( + <> +

Results Over Time

+
+ +
+ + )} +
+ View raw year-by-year data +
+ + + + + + + + + + + + {yearlyData.map((result) => ( + + + + + + + + ))} + +
YearAttainment 8Progress 8Eng & Maths 4+EBacc entry %
{formatAcademicYear(result.year)}{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}
+
+
+
+ ); +} diff --git a/nextjs-app/components/school/WellbeingSection.tsx b/nextjs-app/components/school/WellbeingSection.tsx new file mode 100644 index 0000000..93fef14 --- /dev/null +++ b/nextjs-app/components/school/WellbeingSection.tsx @@ -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 ( +
+

Wellbeing & Context

+ + {/* SEN */} + {(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && ( + <> +

Special Educational Needs (SEN)

+
+ {latestResults?.sen_support_pct != null && ( +
+
+ SEN support + +
+
{formatPercentage(latestResults.sen_support_pct)}
+
Without an EHCP
+
+ )} + {latestResults?.sen_ehcp_pct != null && ( +
+
+ Pupils with EHCP + +
+
{formatPercentage(latestResults.sen_ehcp_pct)}
+
Education, Health and Care Plan
+
+ )} + {(() => { + 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 ( +
+
Total pupils
+
{total.toLocaleString()}
+ {hasSplit && ( + <> +
+ + +
+
+ {girlsPct}% girls + · + {boysPct}% boys +
+ + )} + {schoolInfo.capacity != null && !hasSplit && ( +
Capacity: {schoolInfo.capacity}
+ )} +
+ ); + })()} +
+ + )} + + {/* Deprivation */} + {hasDeprivation && deprivation && ( + <> +

+ Local Area Context + +

+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ ))} +
+
+ Most deprived + Least deprived +
+

{deprivationDesc(deprivation.idaci_decile!)}

+ + )} +
+ ); +} diff --git a/nextjs-app/components/school/sectionShared.tsx b/nextjs-app/components/school/sectionShared.tsx new file mode 100644 index 0000000..38c6e89 --- /dev/null +++ b/nextjs-app/components/school/sectionShared.tsx @@ -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
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 ( +
+ {children} +
+ ); +}