refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.
The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.
Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.
Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
"Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed
The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* The single seam between the characterization tests and the component tree.
|
* The single seam between the characterization tests and the component tree.
|
||||||
*
|
*
|
||||||
* Task 7 of the server/client split rewrites the bodies of these functions to
|
* This file is the ONLY thing the server/client split was allowed to change.
|
||||||
* render the new shell + server-sections composition. Nothing else in the test
|
* It now renders the shell + server-sections composition that
|
||||||
* suite may change — the characterization assertions passing unmodified across
|
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
|
||||||
* that rewrite is the proof that behaviour was preserved.
|
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
|
||||||
*
|
* that is the proof the refactor preserved behaviour.
|
||||||
* National averages now arrive as a server-supplied prop rather than a client
|
|
||||||
* fetch, so no fetch stub is needed.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
|
||||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||||
|
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||||
|
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||||
|
import {
|
||||||
|
computeSchoolFlags, buildNavItems,
|
||||||
|
computeSecondaryFlags, buildSecondaryNavItems,
|
||||||
|
} from '@/lib/schoolSections';
|
||||||
import { nationalAveragesFixture } from './schoolFixtures';
|
import { nationalAveragesFixture } from './schoolFixtures';
|
||||||
|
|
||||||
// Both views call useComparison(), which throws outside the provider. In the
|
// The shell calls useComparison(), which throws outside the provider. In the
|
||||||
// app this wrapper comes from app/layout.tsx.
|
// app this wrapper comes from app/layout.tsx.
|
||||||
function withProviders(ui: ReactNode) {
|
function withProviders(ui: ReactNode) {
|
||||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSchoolDetail(fixture: any) {
|
export function renderSchoolDetail(fixture: any) {
|
||||||
|
const flags = computeSchoolFlags(fixture);
|
||||||
|
const navItems = buildNavItems(flags, {
|
||||||
|
ofsted: fixture.ofsted,
|
||||||
|
admissions: fixture.admissions,
|
||||||
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
|
});
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
withProviders(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />),
|
withProviders(
|
||||||
|
<SchoolDetailShell
|
||||||
|
{...fixture}
|
||||||
|
nationalAvg={nationalAveragesFixture}
|
||||||
|
navItems={navItems}
|
||||||
|
>
|
||||||
|
<PrimarySchoolSections
|
||||||
|
{...fixture}
|
||||||
|
nationalAvg={nationalAveragesFixture}
|
||||||
|
flags={flags}
|
||||||
|
/>
|
||||||
|
</SchoolDetailShell>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSecondarySchoolDetail(fixture: any) {
|
export function renderSecondarySchoolDetail(fixture: any) {
|
||||||
|
const flags = computeSecondaryFlags(fixture);
|
||||||
|
const navItems = buildSecondaryNavItems(flags, {
|
||||||
|
ofsted: fixture.ofsted,
|
||||||
|
admissions: fixture.admissions,
|
||||||
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
|
});
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
withProviders(
|
withProviders(
|
||||||
<SecondarySchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />,
|
<SchoolDetailShell
|
||||||
|
{...fixture}
|
||||||
|
nationalAvg={nationalAveragesFixture}
|
||||||
|
navItems={navItems}
|
||||||
|
>
|
||||||
|
<SecondarySchoolSections
|
||||||
|
{...fixture}
|
||||||
|
nationalAvg={nationalAveragesFixture}
|
||||||
|
flags={flags}
|
||||||
|
/>
|
||||||
|
</SchoolDetailShell>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,13 @@
|
|||||||
|
|
||||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||||
|
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||||
|
import {
|
||||||
|
computeSchoolFlags, buildNavItems,
|
||||||
|
computeSecondaryFlags, buildSecondaryNavItems,
|
||||||
|
} from '@/lib/schoolSections';
|
||||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
import type { NationalAverages } from '@/lib/types';
|
import type { NationalAverages } from '@/lib/types';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
@@ -152,13 +157,30 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
|
|
||||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||||
const isAllThrough = phaseStr === 'all-through';
|
const isAllThrough = phaseStr === 'all-through';
|
||||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||||
const isSecondary = !isAllThrough && (
|
const isSecondary = !isAllThrough && (
|
||||||
phaseStr.includes('secondary')
|
phaseStr.includes('secondary')
|
||||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Section list is computed on the server so the client shell never needs to
|
||||||
|
// derive it -- and so it can never disagree with what the sections render.
|
||||||
|
const sectionInput = {
|
||||||
|
schoolInfo: school_info, yearlyData: yearly_data,
|
||||||
|
absenceData: absence_data, census: census ?? null,
|
||||||
|
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||||
|
};
|
||||||
|
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||||
|
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||||
|
const navInput = {
|
||||||
|
ofsted: ofsted ?? null,
|
||||||
|
admissions: admissions ?? null,
|
||||||
|
yearlyDataLength: yearly_data.length,
|
||||||
|
};
|
||||||
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||||
|
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||||
|
|
||||||
// Generate JSON-LD structured data for SEO
|
// Generate JSON-LD structured data for SEO
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -193,19 +215,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
{isSecondary ? (
|
{isSecondary ? (
|
||||||
<SecondarySchoolDetailView
|
<SchoolDetailShell
|
||||||
schoolInfo={school_info}
|
|
||||||
yearlyData={yearly_data}
|
|
||||||
absenceData={absence_data}
|
|
||||||
ofsted={ofsted ?? null}
|
|
||||||
census={census ?? null}
|
|
||||||
admissions={admissions ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
|
||||||
finance={finance ?? null}
|
|
||||||
nationalAvg={nationalAvg}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SchoolDetailView
|
|
||||||
schoolInfo={school_info}
|
schoolInfo={school_info}
|
||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
@@ -216,7 +226,49 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
|
navItems={secondaryNavItems}
|
||||||
|
>
|
||||||
|
<SecondarySchoolSections
|
||||||
|
schoolInfo={school_info}
|
||||||
|
yearlyData={yearly_data}
|
||||||
|
absenceData={absence_data}
|
||||||
|
ofsted={ofsted ?? null}
|
||||||
|
census={census ?? null}
|
||||||
|
admissions={admissions ?? null}
|
||||||
|
deprivation={deprivation ?? null}
|
||||||
|
finance={finance ?? null}
|
||||||
|
nationalAvg={nationalAvg}
|
||||||
|
flags={secondaryFlags}
|
||||||
/>
|
/>
|
||||||
|
</SchoolDetailShell>
|
||||||
|
) : (
|
||||||
|
<SchoolDetailShell
|
||||||
|
schoolInfo={school_info}
|
||||||
|
yearlyData={yearly_data}
|
||||||
|
absenceData={absence_data}
|
||||||
|
ofsted={ofsted ?? null}
|
||||||
|
census={census ?? null}
|
||||||
|
admissions={admissions ?? null}
|
||||||
|
admissionsHistory={admissions_history ?? []}
|
||||||
|
deprivation={deprivation ?? null}
|
||||||
|
finance={finance ?? null}
|
||||||
|
nationalAvg={nationalAvg}
|
||||||
|
navItems={primaryNavItems}
|
||||||
|
>
|
||||||
|
<PrimarySchoolSections
|
||||||
|
schoolInfo={school_info}
|
||||||
|
yearlyData={yearly_data}
|
||||||
|
absenceData={absence_data}
|
||||||
|
ofsted={ofsted ?? null}
|
||||||
|
census={census ?? null}
|
||||||
|
admissions={admissions ?? null}
|
||||||
|
admissionsHistory={admissions_history ?? []}
|
||||||
|
deprivation={deprivation ?? null}
|
||||||
|
finance={finance ?? null}
|
||||||
|
nationalAvg={nationalAvg}
|
||||||
|
flags={primaryFlags}
|
||||||
|
/>
|
||||||
|
</SchoolDetailShell>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,947 +0,0 @@
|
|||||||
/**
|
|
||||||
* SecondarySchoolDetailView Component
|
|
||||||
* Dedicated detail view for secondary schools with scroll-to-section navigation.
|
|
||||||
* All sections render at once; the sticky nav scrolls to each.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
|
||||||
import { MetricTooltip } from './MetricTooltip';
|
|
||||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
|
||||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
import type {
|
|
||||||
School, SchoolResult, AbsenceData,
|
|
||||||
OfstedInspection, SchoolCensus,
|
|
||||||
SchoolAdmissions,
|
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
|
||||||
} from '@/lib/types';
|
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils';
|
|
||||||
import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections';
|
|
||||||
import { DeltaChip } from './DeltaChip';
|
|
||||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
|
||||||
|
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
|
||||||
if (val == null) return '';
|
|
||||||
if (val > 0) return modStyles.progressPositive;
|
|
||||||
if (val < 0) return modStyles.progressNegative;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deprivationDesc(decile: number): string {
|
|
||||||
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).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecondarySchoolDetailViewProps {
|
|
||||||
schoolInfo: School;
|
|
||||||
yearlyData: SchoolResult[];
|
|
||||||
absenceData: AbsenceData | null;
|
|
||||||
ofsted: OfstedInspection | null;
|
|
||||||
census: SchoolCensus | null;
|
|
||||||
admissions: SchoolAdmissions | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
|
||||||
finance: SchoolFinance | null;
|
|
||||||
/** Fetched on the server so the England-comparison deltas are in the
|
|
||||||
* initial HTML; null when the endpoint is unavailable. */
|
|
||||||
nationalAvg: NationalAverages | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
|
||||||
schoolInfo, yearlyData,
|
|
||||||
ofsted, census, admissions, deprivation, finance, absenceData,
|
|
||||||
nationalAvg,
|
|
||||||
}: SecondarySchoolDetailViewProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
// Hero map — the "View on map" link opens its fullscreen view.
|
|
||||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
|
||||||
const isInComparison = isSelected(schoolInfo.urn);
|
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<string>('');
|
|
||||||
// Header details collapse behind a "Show all details" link on mobile/tablet.
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
||||||
|
|
||||||
// Derived data-shape logic lives in lib/schoolSections so the server route
|
|
||||||
// can compute the section list without importing this client component.
|
|
||||||
const flags = computeSecondaryFlags({ schoolInfo, yearlyData, deprivation, finance });
|
|
||||||
const {
|
|
||||||
latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation,
|
|
||||||
hasWellbeing, hasResults, p8Suspended, isSpecial, suppressComparison,
|
|
||||||
} = flags;
|
|
||||||
|
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
|
||||||
|
|
||||||
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;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handleComparisonToggle = () => {
|
|
||||||
if (isInComparison) {
|
|
||||||
removeSchool(schoolInfo.urn);
|
|
||||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
|
||||||
} else {
|
|
||||||
addSchool(schoolInfo);
|
|
||||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Back returns wherever the user came from; deep-links fall back to search
|
|
||||||
// so the button never dead-ends or leaves the site.
|
|
||||||
const handleBack = () => {
|
|
||||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.push('/search');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
track('school_viewed', {
|
|
||||||
urn: schoolInfo.urn,
|
|
||||||
phase: schoolInfo.phase || 'secondary',
|
|
||||||
local_authority: schoolInfo.local_authority || 'unknown',
|
|
||||||
from: getNavigationSource(),
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [schoolInfo.urn]);
|
|
||||||
|
|
||||||
const navItems = buildSecondaryNavItems(flags, {
|
|
||||||
ofsted, admissions, yearlyDataLength: yearlyData.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track active section as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
const ids = navItems.map(n => n.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
const observers: IntersectionObserver[] = [];
|
|
||||||
const ratioMap: Record<string, number> = {};
|
|
||||||
const pickActive = () => {
|
|
||||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
|
||||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
|
||||||
};
|
|
||||||
ids.forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
ratioMap[id] = 0;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
|
|
||||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
observers.push(obs);
|
|
||||||
});
|
|
||||||
return () => observers.forEach(o => o.disconnect());
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
|
||||||
|
|
||||||
// A report card is identified by the presence of report-card area
|
|
||||||
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
|
||||||
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
|
||||||
// the old `framework === 'ReportCard'` test never matched and report cards
|
|
||||||
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
|
||||||
const isReportCard = !!(
|
|
||||||
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
|
||||||
);
|
|
||||||
// Report cards are dated by their own inspection (rc_inspection_date), never
|
|
||||||
// the legacy inspection_date (report cards exist only from Nov 2025).
|
|
||||||
const ofstedInspectedDate = isReportCard
|
|
||||||
? ofsted?.rc_inspection_date ?? null
|
|
||||||
: ofsted?.inspection_date ?? null;
|
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
|
||||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
|
||||||
const oeifAllSameGrade =
|
|
||||||
!!ofsted &&
|
|
||||||
!isReportCard &&
|
|
||||||
oeifAreas.length >= 3 &&
|
|
||||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
|
||||||
|
|
||||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
|
||||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* Standalone back link, above the header — returns wherever the user
|
|
||||||
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
|
|
||||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
|
||||||
<span aria-hidden="true">←</span> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* ── Header — the location map band blends into the school title ── */}
|
|
||||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
|
||||||
{hasLocation && (
|
|
||||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
|
||||||
)}
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<div className={styles.titleSection}>
|
|
||||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
|
||||||
<div className={styles.badges}>
|
|
||||||
{schoolInfo.school_type && (
|
|
||||||
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
|
||||||
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.age_range && (
|
|
||||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.nursery_provision && (
|
|
||||||
<span className={styles.badge}>Nursery</span>
|
|
||||||
)}
|
|
||||||
{hasSixthForm && (
|
|
||||||
<span className={styles.badge}>Sixth form</span>
|
|
||||||
)}
|
|
||||||
{admissionsTag && (
|
|
||||||
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
|
||||||
{admissionsTag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isProposedToClose(schoolInfo) && (
|
|
||||||
<div className={styles.closingStrip} role="note">
|
|
||||||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
|
||||||
check with the local authority before applying.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{schoolInfo.address && (
|
|
||||||
<p className={styles.address}>
|
|
||||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
|
||||||
{hasLocation && (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.mapLink}
|
|
||||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
|
||||||
>
|
|
||||||
View on map ↗
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.detailsToggle}
|
|
||||||
aria-expanded={detailsOpen}
|
|
||||||
aria-controls="school-header-details"
|
|
||||||
onClick={() => setDetailsOpen((o) => !o)}
|
|
||||||
>
|
|
||||||
{detailsOpen ? 'Hide details' : 'Show all details'}
|
|
||||||
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id="school-header-details"
|
|
||||||
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
|
||||||
>
|
|
||||||
{schoolInfo.headteacher_name && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.website && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<a
|
|
||||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
data-umami-event="external_link_clicked"
|
|
||||||
data-umami-event-target="school_website"
|
|
||||||
>
|
|
||||||
School website ↗
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
|
|
||||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.trust_name && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.telephone && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Phone:</strong>{' '}
|
|
||||||
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
|
||||||
{schoolInfo.telephone}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.religious_denomination && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Religious character:</strong>{' '}
|
|
||||||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
|
||||||
? 'None'
|
|
||||||
: schoolInfo.religious_denomination}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.county && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>County:</strong> {schoolInfo.county}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.parliamentary_constituency && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
onClick={handleComparisonToggle}
|
|
||||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
|
||||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
|
||||||
>
|
|
||||||
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
|
||||||
<span className={styles.btnCompareLabel}>
|
|
||||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
|
||||||
</span>
|
|
||||||
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
|
||||||
{isInComparison ? '✓' : '+'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Sticky section navigation ─────────────────────── */}
|
|
||||||
<nav className={styles.tabNav} aria-label="Page sections">
|
|
||||||
<div className={styles.tabNavInner}>
|
|
||||||
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top">↑ Top</button>
|
|
||||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
|
||||||
{navItems.map(({ id, label }) => (
|
|
||||||
<a
|
|
||||||
key={id}
|
|
||||||
href={`#${id}`}
|
|
||||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
|
||||||
onClick={() => track('section_nav_used', { section: id })}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ── Ofsted ─────────────────────────────────────── */}
|
|
||||||
{ofsted && (
|
|
||||||
<section id="ofsted" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>
|
|
||||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
|
||||||
{ofstedInspectedDate && (
|
|
||||||
<span className={styles.ofstedDate}>
|
|
||||||
{' '}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/${schoolInfo.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 ? (
|
|
||||||
<>
|
|
||||||
<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.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).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>
|
|
||||||
</>
|
|
||||||
) : ofsted.overall_effectiveness ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.ofstedHeader}>
|
|
||||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
|
||||||
{OFSTED_LABELS[ofsted.overall_effectiveness]}
|
|
||||||
</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.'
|
|
||||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── GCSE Results ───────────────────────────────── */}
|
|
||||||
{hasResults && latestResults && (
|
|
||||||
<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, styles)}`}>
|
|
||||||
{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, styles)}`}>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Admissions ─────────────────────────────────── */}
|
|
||||||
{admissions && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── History table ──────────────────────────────── */}
|
|
||||||
{yearlyData.length > 1 && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
|
||||||
{hasWellbeing && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Finances ───────────────────────────────────── */}
|
|
||||||
{hasFinance && finance && (
|
|
||||||
<section id="finances" className={styles.card}>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,15 +11,14 @@
|
|||||||
* toggle renders at all, so such pages ship zero admissions JavaScript.
|
* toggle renders at all, so such pages ship zero admissions JavaScript.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||||
|
import { AdmissionsTrendChart } from './charts';
|
||||||
|
|
||||||
const AdmissionsTrendChart = dynamic(() => import('../AdmissionsTrendChart'), { ssr: false });
|
|
||||||
|
|
||||||
export function AdmissionsSection({
|
export function AdmissionsSection({
|
||||||
admissions,
|
admissions,
|
||||||
|
|||||||
@@ -4,16 +4,10 @@
|
|||||||
* were only 40% similar). Server component.
|
* were only 40% similar). Server component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
|
import { PerformanceChart, SatsChart } from './charts';
|
||||||
const PerformanceChart = dynamic(
|
|
||||||
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
|
|
||||||
|
|
||||||
export function HistorySection({
|
export function HistorySection({
|
||||||
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
|
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* PrimarySchoolSections — the section sequence for primary and all-through
|
||||||
|
* detail pages. Server component.
|
||||||
|
*
|
||||||
|
* All-through schools route here rather than to SecondarySchoolSections,
|
||||||
|
* because this list renders BOTH the KS2 and KS4 blocks (ResultsSection and
|
||||||
|
* HistorySection branch on isAllThrough); the secondary list is KS4-only and
|
||||||
|
* would silently drop their SATs data.
|
||||||
|
*
|
||||||
|
* The render conditions here MUST match buildNavItems in lib/schoolSections,
|
||||||
|
* or the sticky nav will link to sections that do not exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
|
import { OfstedSection } from './OfstedSection';
|
||||||
|
import { ResultsSection } from './ResultsSection';
|
||||||
|
import { AdmissionsSection } from './AdmissionsSection';
|
||||||
|
import { InclusionSection } from './InclusionSection';
|
||||||
|
import { HistorySection } from './HistorySection';
|
||||||
|
import { SchoolLifeSection } from './SchoolLifeSection';
|
||||||
|
import { LocalAreaSection } from './LocalAreaSection';
|
||||||
|
import { FinancesSection } from './FinancesSection';
|
||||||
|
|
||||||
|
export interface PrimarySchoolSectionsProps {
|
||||||
|
schoolInfo: School;
|
||||||
|
yearlyData: SchoolResult[];
|
||||||
|
absenceData: AbsenceData | null;
|
||||||
|
ofsted: OfstedInspection | null;
|
||||||
|
census: SchoolCensus | null;
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionsHistory: SchoolAdmissions[];
|
||||||
|
deprivation: SchoolDeprivation | null;
|
||||||
|
finance: SchoolFinance | null;
|
||||||
|
nationalAvg: NationalAverages | null;
|
||||||
|
flags: SchoolFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimarySchoolSections({
|
||||||
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
|
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
||||||
|
}: PrimarySchoolSectionsProps) {
|
||||||
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
|
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
||||||
|
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||||
|
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
|
const oeifAllSameGrade =
|
||||||
|
!!ofsted &&
|
||||||
|
!isReportCard &&
|
||||||
|
oeifAreas.length >= 3 &&
|
||||||
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ofsted && (
|
||||||
|
<OfstedSection
|
||||||
|
ofsted={ofsted}
|
||||||
|
urn={schoolInfo.urn}
|
||||||
|
isReportCard={isReportCard}
|
||||||
|
ofstedInspectedDate={ofstedInspectedDate}
|
||||||
|
oeifAllSameGrade={oeifAllSameGrade}
|
||||||
|
oeifAreas={oeifAreas}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasAnyResults && flags.latestResults && (
|
||||||
|
<ResultsSection
|
||||||
|
latestResults={flags.latestResults}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
primaryAvg={primaryAvg}
|
||||||
|
secondaryAvg={secondaryAvg}
|
||||||
|
isAllThrough={flags.isAllThrough}
|
||||||
|
isSecondary={flags.isSecondary}
|
||||||
|
isSpecial={flags.isSpecial}
|
||||||
|
hasKS2Results={flags.hasKS2Results}
|
||||||
|
hasKS4Results={flags.hasKS4Results}
|
||||||
|
ks2Placeholder={flags.ks2Placeholder}
|
||||||
|
suppressKs2Comparison={flags.suppressKs2Comparison}
|
||||||
|
suppressKs4Comparison={flags.suppressKs4Comparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admissions && (
|
||||||
|
<AdmissionsSection
|
||||||
|
admissions={admissions}
|
||||||
|
admissionsHistory={admissionsHistory}
|
||||||
|
isAllThrough={flags.isAllThrough}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasInclusionData && (
|
||||||
|
<InclusionSection
|
||||||
|
latestResults={flags.latestResults}
|
||||||
|
census={census}
|
||||||
|
hasGenderSplit={flags.hasGenderSplit}
|
||||||
|
primaryAvg={primaryAvg}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yearlyData.length > 0 && (
|
||||||
|
<HistorySection
|
||||||
|
yearlyData={yearlyData}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
nationalAvg={nationalAvg}
|
||||||
|
primaryAvg={primaryAvg}
|
||||||
|
secondaryAvg={secondaryAvg}
|
||||||
|
isAllThrough={flags.isAllThrough}
|
||||||
|
isPrimary={flags.isPrimary}
|
||||||
|
isSecondary={flags.isSecondary}
|
||||||
|
hasKS2Results={flags.hasKS2Results}
|
||||||
|
hasKS4Results={flags.hasKS4Results}
|
||||||
|
suppressKs2Comparison={flags.suppressKs2Comparison}
|
||||||
|
suppressKs4Comparison={flags.suppressKs4Comparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasSchoolLife && (
|
||||||
|
<SchoolLifeSection absenceData={absenceData} primaryAvg={primaryAvg} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasDeprivation && deprivation && (
|
||||||
|
<LocalAreaSection deprivation={deprivation} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,14 @@
|
|||||||
* Primary and all-through pages. Server component.
|
* Primary and all-through pages. Server component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { School, SchoolResult } from '@/lib/types';
|
import type { School, SchoolResult } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||||
import { MetricTooltip } from '../MetricTooltip';
|
import { MetricTooltip } from '../MetricTooltip';
|
||||||
import { DeltaChip } from '../DeltaChip';
|
import { DeltaChip } from '../DeltaChip';
|
||||||
import { SpecialSchoolNote } from '../SpecialSchoolNote';
|
import { SpecialSchoolNote } from '../SpecialSchoolNote';
|
||||||
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
|
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
|
||||||
|
import { SatsChart } from './charts';
|
||||||
|
|
||||||
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
|
|
||||||
|
|
||||||
export function ResultsSection({
|
export function ResultsSection({
|
||||||
latestResults, schoolInfo, primaryAvg, secondaryAvg,
|
latestResults, schoolInfo, primaryAvg, secondaryAvg,
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
/* Styles for SchoolDetailShell — the interactive chrome of a detail page.
|
||||||
|
Derived from the classes the shell's JSX references; the section styles
|
||||||
|
live in components/school/schoolSections.module.css. Classes used by both
|
||||||
|
appear in both files, which is correct: CSS Modules hash them per-file. */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Standalone back link, sits above the header card on the page background. */
|
||||||
|
.topBack {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.topBack:hover {
|
||||||
|
color: var(--accent-coral-dark, #c85a3e);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 10px;
|
||||||
|
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* With a map band above, slide the title up under the fade so map and title
|
||||||
|
read as one object; the Compare button floats glassy over the map. */
|
||||||
|
.headerHasMap .headerContent {
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* The title (not the whole content row) rises above the map fade. Keeping
|
||||||
|
.headerContent unpositioned matters: .actions must anchor to .header so it
|
||||||
|
floats over the map band, not over the title. */
|
||||||
|
.headerHasMap .titleSection {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerHasMap .actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 6;
|
||||||
|
margin: 0;
|
||||||
|
/* Beat the mobile `.actions { width: 100% }` rule — a floating button
|
||||||
|
must never stretch across the title. */
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnAdd {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
border-color: transparent;
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnAdd:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Full label by default; phones over the map get an icon-only button
|
||||||
|
(same compact treatment as the section-nav compare icon). */
|
||||||
|
.btnCompareGlyph {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Inline "View on map ↗" trigger next to the address. */
|
||||||
|
.mapLink {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mapLink:hover {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.titleSection {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.schoolName {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.metaItem {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.address {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Expanded header details (headteacher, website, trust, pupils) */
|
||||||
|
.headerDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerDetail {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerDetail strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerDetail a {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerDetail a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* "Show all details" reveal — only rendered on mobile/tablet, where the
|
||||||
|
header details block is collapsed below the fold. Hidden on desktop. */
|
||||||
|
.detailsToggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btnAdd,
|
||||||
|
.btnRemove {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btnAdd {
|
||||||
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btnAdd:hover {
|
||||||
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btnRemove {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btnRemove:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Sticky Section Navigation ──────────────────────── */
|
||||||
|
/* Docks directly under the global header; Back and "All" stay pinned while
|
||||||
|
only the section links scroll. */
|
||||||
|
.sectionNav {
|
||||||
|
position: sticky;
|
||||||
|
top: 64px; /* global header height on desktop */
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavBack {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.3rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavBack:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* The scrolling middle: section links only. */
|
||||||
|
.sectionNavLinks {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
scroll-padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavLinks::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.3rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavLink:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavLinkActive {
|
||||||
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavLinkActive:hover {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Mobile: the scrolling links collapse into one "section" menu button ──
|
||||||
|
(hidden on desktop, where the links fit). */
|
||||||
|
.sectionNavMenu {
|
||||||
|
display: none; /* shown only ≤640px */
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.34rem 0.7rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavMenuCur {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavMenuEyebrow {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavMenuNow {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavMenuChev {
|
||||||
|
flex: none;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Compact icon version of the Compare CTA, used on mobile. */
|
||||||
|
.sectionNavCompareIcon {
|
||||||
|
display: none; /* shown only ≤640px */
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||||
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompareIcon svg {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompareBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
border: 1.5px solid var(--accent-coral, #e07256);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompareIconIn {
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Compare CTA carried into the bar once the hero's button scrolls away. */
|
||||||
|
.sectionNavCompare {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.34rem 0.7rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
|
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompare:hover {
|
||||||
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
|
border-color: var(--accent-coral-darker, #9c3f26);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompareIn {
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavCompareIn:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* "All ▾" jump menu (desktop). */
|
||||||
|
.sectionNavAll {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.34rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionNavAll:hover {
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1500;
|
||||||
|
background: rgba(26, 22, 18, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsPanel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 1600;
|
||||||
|
width: 230px;
|
||||||
|
max-height: min(70vh, 460px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsPanelHead {
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
padding: 0.4rem 0.6rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsItem:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsItemActive {
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sectionsTick {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* GIAS "Open, but proposed to close" notice strip */
|
||||||
|
.closingStrip {
|
||||||
|
background: #fdf6e3;
|
||||||
|
border-left: 4px solid #e2c96f;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #6e5a00;
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closingStrip strong {
|
||||||
|
color: #8a6200;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnCompareLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnCompareGlyph {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnAdd,
|
||||||
|
.headerHasMap .actions .btnRemove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
.sectionNav {
|
||||||
|
top: 56px; /* global header is shorter on mobile */
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
.sectionNavLink,
|
||||||
|
.sectionNavBack {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
.sectionNavCompare {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
.sectionNavAll {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
/**
|
||||||
|
* SchoolDetailShell — the interactive chrome of a school detail page.
|
||||||
|
*
|
||||||
|
* The ONLY large client component on the route. Everything below the sticky
|
||||||
|
* nav is server-rendered and arrives as `children`, composed in
|
||||||
|
* app/school/[slug]/page.tsx. That indirection is required: a server component
|
||||||
|
* imported by a client component becomes a client component, so the sections
|
||||||
|
* cannot be imported here.
|
||||||
|
*
|
||||||
|
* What stays client-side is genuinely interactive: router.back(), the header
|
||||||
|
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
|
||||||
|
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
|
||||||
|
* sections with document.getElementById, so it works unchanged against
|
||||||
|
* server-rendered children.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
||||||
|
import type {
|
||||||
|
School, SchoolResult, AbsenceData,
|
||||||
|
OfstedInspection, SchoolCensus,
|
||||||
|
SchoolAdmissions,
|
||||||
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import {
|
||||||
|
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas,
|
||||||
|
} from '@/lib/utils';
|
||||||
|
import { computeSchoolFlags, type NavItem } from '@/lib/schoolSections';
|
||||||
|
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
|
import styles from './SchoolDetailShell.module.css';
|
||||||
|
|
||||||
|
|
||||||
|
export interface SchoolDetailShellProps {
|
||||||
|
schoolInfo: School;
|
||||||
|
yearlyData: SchoolResult[];
|
||||||
|
absenceData: AbsenceData | null;
|
||||||
|
ofsted: OfstedInspection | null;
|
||||||
|
census: SchoolCensus | null;
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionsHistory: SchoolAdmissions[];
|
||||||
|
deprivation: SchoolDeprivation | null;
|
||||||
|
finance: SchoolFinance | null;
|
||||||
|
/** Fetched on the server so the England-comparison deltas are in the
|
||||||
|
* initial HTML; null when the endpoint is unavailable. */
|
||||||
|
nationalAvg: NationalAverages | null;
|
||||||
|
/** Section list for the sticky nav, computed on the server. */
|
||||||
|
navItems: NavItem[];
|
||||||
|
/** The server-rendered sections. */
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchoolDetailShell({
|
||||||
|
schoolInfo, yearlyData, absenceData,
|
||||||
|
ofsted, census, admissions, admissionsHistory, deprivation, finance,
|
||||||
|
nationalAvg, navItems, children,
|
||||||
|
}: SchoolDetailShellProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
|
// Admissions view state moved to AdmissionsViewToggle, the client island
|
||||||
|
// inside the (server-rendered) admissions section.
|
||||||
|
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||||
|
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||||
|
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
|
||||||
|
const heroActionsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
|
||||||
|
// Hero map — the "View on map" link opens its fullscreen view.
|
||||||
|
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||||
|
// "All ▾" jump menu listing every section.
|
||||||
|
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||||
|
// Header details (headteacher, contact, trust, area) collapse behind a
|
||||||
|
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||||||
|
// fall back to search so the button never dead-ends or leaves the site.
|
||||||
|
const handleBack = () => {
|
||||||
|
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push('/search');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sectionLinksRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const overflow = el.scrollWidth - el.clientWidth;
|
||||||
|
// No overflow → treat as "at end" so the fade is hidden.
|
||||||
|
if (overflow <= 1) {
|
||||||
|
setSectionNavAtEnd(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
el.addEventListener('scroll', update, { passive: true });
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', update);
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track whether the hero's "Add to Compare" button is still on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = heroActionsRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => setHeroCtaVisible(entry.isIntersecting),
|
||||||
|
{ rootMargin: '-64px 0px 0px 0px' },
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close the "All ▾" menu on Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sectionsOpen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [sectionsOpen]);
|
||||||
|
|
||||||
|
// Derived data-shape logic lives in lib/schoolSections so the server route
|
||||||
|
// can compute the section list without importing this client component.
|
||||||
|
const flags = computeSchoolFlags({
|
||||||
|
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
latestResults, isAllThrough, isSecondary, isPrimary,
|
||||||
|
hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation,
|
||||||
|
hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults,
|
||||||
|
isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
|
||||||
|
} = flags;
|
||||||
|
const phase = schoolInfo.phase ?? '';
|
||||||
|
|
||||||
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
|
const handleComparisonToggle = () => {
|
||||||
|
if (isInComparison) {
|
||||||
|
removeSchool(schoolInfo.urn);
|
||||||
|
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
|
} else {
|
||||||
|
addSchool(schoolInfo);
|
||||||
|
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page-view event with funnel attribution. Fires once per mount.
|
||||||
|
useEffect(() => {
|
||||||
|
track('school_viewed', {
|
||||||
|
urn: schoolInfo.urn,
|
||||||
|
phase: phase || 'unknown',
|
||||||
|
local_authority: schoolInfo.local_authority || 'unknown',
|
||||||
|
from: getNavigationSource(),
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [schoolInfo.urn]);
|
||||||
|
|
||||||
|
const 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).`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Track active section as user scrolls
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = navItems.map(n => n.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
const ratioMap: Record<string, number> = {};
|
||||||
|
|
||||||
|
const pickActive = () => {
|
||||||
|
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
ratioMap[id] = 0;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
ratioMap[id] = entry.intersectionRatio;
|
||||||
|
pickActive();
|
||||||
|
},
|
||||||
|
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
observers.push(obs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observers.forEach(o => o.disconnect());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// A report card is dated by its own inspection (rc_inspection_date); the
|
||||||
|
// legacy inspection_date belongs to an older inspection and must never
|
||||||
|
// date a report card (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
|
const oeifAllSameGrade =
|
||||||
|
!!ofsted &&
|
||||||
|
!isReportCard &&
|
||||||
|
oeifAreas.length >= 3 &&
|
||||||
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
|
|
||||||
|
// Label shown in the mobile "section" menu button — the section in view.
|
||||||
|
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Standalone back link, above the header — returns to wherever the
|
||||||
|
user came from. Scrolls away with the page (the sticky bar keeps a
|
||||||
|
"back to top" control in its place). */}
|
||||||
|
<button type="button" onClick={handleBack} className={styles.topBack}>
|
||||||
|
<span aria-hidden="true">←</span> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header — the location map band blends down into the school title. */}
|
||||||
|
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||||
|
{hasLocation && (
|
||||||
|
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||||
|
)}
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.titleSection}>
|
||||||
|
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
{schoolInfo.local_authority && (
|
||||||
|
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.school_type && (
|
||||||
|
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||||
|
)}
|
||||||
|
{isAllThrough && (
|
||||||
|
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.age_range && (
|
||||||
|
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.nursery_provision && (
|
||||||
|
<span className={styles.metaItem}>Nursery</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.has_sixth_form && (
|
||||||
|
<span className={styles.metaItem}>Sixth form</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isProposedToClose(schoolInfo) && (
|
||||||
|
<div className={styles.closingStrip} role="note">
|
||||||
|
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||||
|
check with the local authority before applying.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schoolInfo.address && (
|
||||||
|
<p className={styles.address}>
|
||||||
|
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||||
|
{hasLocation && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.mapLink}
|
||||||
|
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||||
|
>
|
||||||
|
View on map ↗
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.detailsToggle}
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
aria-controls="school-header-details"
|
||||||
|
onClick={() => setDetailsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||||||
|
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="school-header-details"
|
||||||
|
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||||||
|
>
|
||||||
|
{schoolInfo.headteacher_name && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.website && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<a
|
||||||
|
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="school_website"
|
||||||
|
>
|
||||||
|
School website ↗
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||||
|
if (total == null) return null;
|
||||||
|
return (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Pupils:</strong> {total.toLocaleString()}
|
||||||
|
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{schoolInfo.trust_name && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.telephone && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Phone:</strong>{' '}
|
||||||
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||||
|
{schoolInfo.telephone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.religious_denomination && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Religious character:</strong>{' '}
|
||||||
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||||
|
? 'None'
|
||||||
|
: schoolInfo.religious_denomination}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.county && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>County:</strong> {schoolInfo.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.parliamentary_constituency && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions} ref={heroActionsRef}>
|
||||||
|
<button
|
||||||
|
onClick={handleComparisonToggle}
|
||||||
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||||
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||||
|
>
|
||||||
|
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||||
|
<span className={styles.btnCompareLabel}>
|
||||||
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||||
|
{isInComparison ? '✓' : '+'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Sticky Section Navigation — docks under the global header */}
|
||||||
|
<nav className={styles.sectionNav} aria-label="Page sections">
|
||||||
|
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
<span className={styles.sectionNavBackLabel}>Top</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Desktop: scrolling section links */}
|
||||||
|
<div
|
||||||
|
ref={sectionLinksRef}
|
||||||
|
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||||
|
>
|
||||||
|
{navItems.map(({ id, label }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||||
|
onClick={() => track('section_nav_used', { section: id })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: a single "section" menu button that opens the jump sheet */}
|
||||||
|
{navItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sectionNavMenu}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={sectionsOpen}
|
||||||
|
onClick={() => setSectionsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<span className={styles.sectionNavMenuCur}>
|
||||||
|
<span className={styles.sectionNavMenuEyebrow}>Section</span>
|
||||||
|
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.sectionNavMenuChev} aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The hero's Compare CTA, carried in once it scrolls out of view.
|
||||||
|
Desktop shows a labelled pill; mobile a compact icon. */}
|
||||||
|
{!heroCtaVisible && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleComparisonToggle}
|
||||||
|
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
|
||||||
|
>
|
||||||
|
{isInComparison ? '✓ Comparing' : '+ Compare'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleComparisonToggle}
|
||||||
|
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
|
||||||
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||||
|
title={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||||
|
>
|
||||||
|
{isInComparison ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m5 12 5 5 9-11" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M4 7h13l-3-3" />
|
||||||
|
<path d="M20 17H7l3 3" />
|
||||||
|
</svg>
|
||||||
|
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
|
||||||
|
{navItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sectionNavAll}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={sectionsOpen}
|
||||||
|
onClick={() => setSectionsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
All <span aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
|
||||||
|
{sectionsOpen && (
|
||||||
|
<>
|
||||||
|
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
|
||||||
|
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
|
||||||
|
<div className={styles.sectionsPanelHead}>Jump to section</div>
|
||||||
|
{navItems.map(({ id, label }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
role="menuitem"
|
||||||
|
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSectionsOpen(false);
|
||||||
|
track('section_nav_used', { section: id, via: 'all_menu' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true">✓</span>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,11 @@
|
|||||||
* Server component.
|
* Server component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
|
import { PerformanceChart } from './charts';
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
|
||||||
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
export function SecondaryHistorySection({
|
export function SecondaryHistorySection({
|
||||||
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
|
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* SecondarySchoolSections — the section sequence for secondary detail pages.
|
||||||
|
* Server component.
|
||||||
|
*
|
||||||
|
* Wrapped in `.secondaryScope`, which activates the secondary-only style
|
||||||
|
* overrides in schoolSections.module.css. Those rules target class names the
|
||||||
|
* primary page also uses (.card, .sectionTitle, .metricCard …), so scoping is
|
||||||
|
* what keeps them from restyling primary pages.
|
||||||
|
*
|
||||||
|
* The render conditions here MUST match buildSecondaryNavItems in
|
||||||
|
* lib/schoolSections, or the sticky nav will link to sections that do not exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
|
import { OfstedSection } from './OfstedSection';
|
||||||
|
import { GcseSection } from './GcseSection';
|
||||||
|
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||||
|
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||||
|
import { WellbeingSection } from './WellbeingSection';
|
||||||
|
import { FinancesSection } from './FinancesSection';
|
||||||
|
import styles from './schoolSections.module.css';
|
||||||
|
|
||||||
|
export interface SecondarySchoolSectionsProps {
|
||||||
|
schoolInfo: School;
|
||||||
|
yearlyData: SchoolResult[];
|
||||||
|
absenceData: AbsenceData | null;
|
||||||
|
ofsted: OfstedInspection | null;
|
||||||
|
census: SchoolCensus | null;
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
|
deprivation: SchoolDeprivation | null;
|
||||||
|
finance: SchoolFinance | null;
|
||||||
|
nationalAvg: NationalAverages | null;
|
||||||
|
flags: SecondaryFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecondarySchoolSections({
|
||||||
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
|
admissions, deprivation, finance, nationalAvg, flags,
|
||||||
|
}: SecondarySchoolSectionsProps) {
|
||||||
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
|
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
|
const oeifAllSameGrade =
|
||||||
|
!!ofsted &&
|
||||||
|
!isReportCard &&
|
||||||
|
oeifAreas.length >= 3 &&
|
||||||
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.secondaryScope}>
|
||||||
|
{ofsted && (
|
||||||
|
<OfstedSection
|
||||||
|
ofsted={ofsted}
|
||||||
|
urn={schoolInfo.urn}
|
||||||
|
isReportCard={isReportCard}
|
||||||
|
ofstedInspectedDate={ofstedInspectedDate}
|
||||||
|
oeifAllSameGrade={oeifAllSameGrade}
|
||||||
|
oeifAreas={oeifAreas}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasResults && flags.latestResults && (
|
||||||
|
<GcseSection
|
||||||
|
latestResults={flags.latestResults}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
secondaryAvg={secondaryAvg}
|
||||||
|
p8Suspended={flags.p8Suspended}
|
||||||
|
suppressComparison={flags.suppressComparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admissions && (
|
||||||
|
<SecondaryAdmissionsSection
|
||||||
|
admissions={admissions}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
hasSixthForm={flags.hasSixthForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yearlyData.length > 1 && (
|
||||||
|
<SecondaryHistorySection
|
||||||
|
yearlyData={yearlyData}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
nationalAvg={nationalAvg}
|
||||||
|
secondaryAvg={secondaryAvg}
|
||||||
|
suppressComparison={flags.suppressComparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasWellbeing && (
|
||||||
|
<WellbeingSection
|
||||||
|
latestResults={flags.latestResults}
|
||||||
|
census={census}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
|
deprivation={deprivation}
|
||||||
|
hasDeprivation={flags.hasDeprivation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags.hasFinance && finance && (
|
||||||
|
<FinancesSection finance={finance} showPremises />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client wrappers for the lazily-loaded charts.
|
||||||
|
*
|
||||||
|
* `next/dynamic` with `ssr: false` is only legal inside a Client Component,
|
||||||
|
* and the sections that render charts are Server Components. These one-line
|
||||||
|
* wrappers are the client boundary, so the charts stay browser-only and
|
||||||
|
* code-split while the section markup around them stays on the server.
|
||||||
|
*
|
||||||
|
* Chart.js is ~64 KB gzipped, so keeping it lazy matters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const PerformanceChart = dynamic(
|
||||||
|
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
|
||||||
|
|
||||||
|
export const AdmissionsTrendChart = dynamic(
|
||||||
|
() => import('../AdmissionsTrendChart'),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user