From 752eb07310b375b9eae31811fd7c1522b44185aa Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 2 Aug 2026 21:39:21 +0100 Subject: [PATCH] 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 --- .../__tests__/support/renderSchoolDetail.tsx | 63 +- nextjs-app/app/school/[slug]/page.tsx | 88 +- .../components/SchoolDetailView.module.css | 1624 ----------------- nextjs-app/components/SchoolDetailView.tsx | 1287 ------------- .../SecondarySchoolDetailView.module.css | 1185 ------------ .../components/SecondarySchoolDetailView.tsx | 947 ---------- .../components/school/AdmissionsSection.tsx | 3 +- .../components/school/HistorySection.tsx | 8 +- .../school/PrimarySchoolSections.tsx | 139 ++ .../components/school/ResultsSection.tsx | 3 +- .../school/SchoolDetailShell.module.css | 669 +++++++ .../components/school/SchoolDetailShell.tsx | 512 ++++++ .../school/SecondaryHistorySection.tsx | 6 +- .../school/SecondarySchoolSections.tsx | 115 ++ nextjs-app/components/school/charts.tsx | 26 + 15 files changed, 1586 insertions(+), 5089 deletions(-) delete mode 100644 nextjs-app/components/SchoolDetailView.module.css delete mode 100644 nextjs-app/components/SchoolDetailView.tsx delete mode 100644 nextjs-app/components/SecondarySchoolDetailView.module.css delete mode 100644 nextjs-app/components/SecondarySchoolDetailView.tsx create mode 100644 nextjs-app/components/school/PrimarySchoolSections.tsx create mode 100644 nextjs-app/components/school/SchoolDetailShell.module.css create mode 100644 nextjs-app/components/school/SchoolDetailShell.tsx create mode 100644 nextjs-app/components/school/SecondarySchoolSections.tsx create mode 100644 nextjs-app/components/school/charts.tsx diff --git a/nextjs-app/__tests__/support/renderSchoolDetail.tsx b/nextjs-app/__tests__/support/renderSchoolDetail.tsx index 38707ac..f8ca675 100644 --- a/nextjs-app/__tests__/support/renderSchoolDetail.tsx +++ b/nextjs-app/__tests__/support/renderSchoolDetail.tsx @@ -1,38 +1,77 @@ /** * 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 - * render the new shell + server-sections composition. Nothing else in the test - * suite may change — the characterization assertions passing unmodified across - * that rewrite is the proof that behaviour was preserved. - * - * National averages now arrive as a server-supplied prop rather than a client - * fetch, so no fetch stub is needed. + * This file is the ONLY thing the server/client split was allowed to change. + * It now renders the shell + server-sections composition that + * app/school/[slug]/page.tsx builds, instead of the old monolithic views. + * Every assertion in schoolDetail.characterization.test.tsx is unchanged — + * that is the proof the refactor preserved behaviour. */ import { render } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { SchoolDetailView } from '@/components/SchoolDetailView'; -import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView'; 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'; -// 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. function withProviders(ui: ReactNode) { return {ui}; } 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( - withProviders(), + withProviders( + + + , + ), ); } 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( withProviders( - , + + + , ), ); } diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 827e6d6..6e90198 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -6,8 +6,13 @@ import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api'; import { notFound, redirect } from 'next/navigation'; -import { SchoolDetailView } from '@/components/SchoolDetailView'; -import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView'; +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 { parseSchoolSlug, schoolUrl } from '@/lib/utils'; import type { NationalAverages } from '@/lib/types'; import type { Metadata } from 'next'; @@ -152,13 +157,30 @@ export default async function SchoolPage({ params }: SchoolPageProps) { const phaseStr = (school_info.phase ?? '').toLowerCase(); const isAllThrough = phaseStr === 'all-through'; - // All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections). - // SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data. + // All-through schools go to PrimarySchoolSections (renders both KS2 + KS4). + // SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data. const isSecondary = !isAllThrough && ( phaseStr.includes('secondary') || 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 const structuredData = { '@context': 'https://schema.org', @@ -193,19 +215,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} /> {isSecondary ? ( - - ) : ( - + navItems={secondaryNavItems} + > + + + ) : ( + + + )} ); diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css deleted file mode 100644 index 5286103..0000000 --- a/nextjs-app/components/SchoolDetailView.module.css +++ /dev/null @@ -1,1624 +0,0 @@ -.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; -} - -@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; - } -} - -/* 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; -} - -/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */ -.genderSplitValue { - display: flex; - align-items: baseline; - gap: 0.3rem; - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - font-size: 1.55rem; - font-weight: 700; - line-height: 1; - flex-wrap: wrap; -} - -.genderSplitGirls { - color: #a04a68; -} - -.genderSplitBoys { - color: var(--accent-teal, #2d7d7d); -} - -.genderSplitLabel { - font-family: var(--font-body, inherit); - font-size: 0.78rem; - font-weight: 500; - color: var(--text-muted, #6d685f); - letter-spacing: 0; -} - -.genderSplitSep { - color: var(--border-color, #c8beb0); - font-weight: 400; - font-size: 1.2rem; - padding: 0 0.1rem; -} - -.genderBar { - display: flex; - height: 6px; - border-radius: 999px; - overflow: hidden; - background: var(--border-color, #e5dfd5); - width: 100%; -} - -.genderBarGirls { - background: #a04a68; -} - -.genderBarBoys { - background: var(--accent-teal, #2d7d7d); -} - -.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; -} - -@media (max-width: 640px) { - .sectionNav { - top: 56px; /* global header is shorter on mobile */ - padding: 0.4rem 0.6rem; - gap: 0.375rem; - } -} - -.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; -} - -/* Right-edge fade so users see there's more to scroll to. */ -@media (max-width: 640px) { - .sectionNavLinks { - -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); - mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); - } - - /* When scrolled to the end, drop the fade so the last item isn't dimmed. */ - .sectionNavLinks.atEnd { - -webkit-mask-image: none; - mask-image: 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; -} - -@media (max-width: 640px) { - .sectionNavLink, - .sectionNavBack { - min-height: 36px; - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - } -} - -.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); -} - -@media (max-width: 640px) { - .sectionNavCompare { - min-height: 36px; - } -} - -/* "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); -} - -@media (max-width: 640px) { - .sectionNavAll { - min-height: 36px; - } -} - -.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); -} - -/* On phones the menu becomes a bottom sheet. */ -@media (max-width: 640px) { - .sectionsPanel { - position: fixed; - top: auto; - left: 0; - right: 0; - bottom: 0; - width: auto; - max-height: 74vh; - border-radius: 16px 16px 0 0; - padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0)); - box-shadow: 0 -10px 40px rgba(26, 22, 18, 0.25); - } - - .sectionsItem { - padding: 0.7rem 0.6rem; - font-size: 0.9rem; - } -} - -/* Swap which controls show at the mobile breakpoint. Declared last so these - display rules win over the base (equal-specificity) declarations above. */ -@media (max-width: 640px) { - .sectionNavLinks, - .sectionNavCompare, - .sectionNavAll, - .sectionNavBackLabel { - display: none; - } - - .sectionNavMenu { - display: flex; - } - - .sectionNavCompareIcon { - display: inline-flex; - } -} - -/* Unified card for all content sections */ -.card { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 10px; - padding: 1.25rem 1.5rem; - margin-bottom: 1rem; - box-shadow: var(--shadow-soft); - scroll-margin-top: 6rem; - min-width: 0; - max-width: 100%; -} - -/* Section Title */ -.sectionTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin-bottom: 0.875rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--border-color, #e5dfd5); - font-family: var(--font-playfair), "Playfair Display", serif; - display: flex; - align-items: center; - gap: 0.375rem; - flex-wrap: wrap; -} - -.sectionTitle::before { - content: ""; - display: inline-block; - width: 3px; - height: 1em; - background: var(--accent-coral, #e07256); - border-radius: 2px; - flex-shrink: 0; -} - -.sectionSubtitle { - font-size: 0.85rem; - color: var(--text-muted, #8a847a); - margin: -0.5rem 0 1rem; -} - -.subSectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-secondary, #5c564d); - margin: 1.25rem 0 0.75rem; -} - -/* Metrics Grid & Cards */ -.metricsGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.75rem; -} - -.metricCard { - background: var(--bg-secondary, #f3ede4); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 6px; - padding: 0.75rem; - text-align: center; -} - -.metricLabel { - font-size: 0.6875rem; - color: var(--text-muted, #8a847a); - margin-bottom: 0.25rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.metricValue { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary, #1a1612); - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; -} - -.metricHint { - font-size: 0.7rem; - color: var(--text-muted, #8a847a); - margin-top: 0.3rem; - font-style: italic; -} - -/* ── Hero stat cards (RWM combined, Pupils & Inclusion, etc.) ── */ -/* Larger teal-tinted cards with Playfair serif numbers — reserved for - the top-of-section headline metrics. Use .metricCard for denser - secondary metrics. */ -.heroStatGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 0.85rem; - margin-bottom: 0.25rem; -} - -.heroStatCard { - background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12)); - border: 1px solid rgba(45, 125, 125, 0.2); - border-radius: 12px; - padding: 1rem 1.1rem; - display: flex; - flex-direction: column; - gap: 0.4rem; - text-align: left; -} - -.heroStatLabel { - font-size: 0.6rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted, #6d685f); - display: flex; - align-items: center; - gap: 0.3rem; - line-height: 1.3; -} - -.heroStatValue { - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - font-size: 2.1rem; - font-weight: 700; - line-height: 1; - color: var(--accent-teal, #2d7d7d); - display: flex; - align-items: baseline; - gap: 0.5rem; - flex-wrap: wrap; - justify-content: flex-start; -} - -.heroStatHint { - font-size: 0.7rem; - color: var(--text-muted, #6d685f); - font-style: normal; - margin-top: 0; -} - -@media (max-width: 480px) { - .heroStatGrid { - grid-template-columns: 1fr; - } - - .heroStatValue { - font-size: 1.85rem; - } -} - -/* Progress score colour coding */ -.progressPositive { - color: var(--accent-teal, #2d7d7d); - font-weight: 700; -} - -.progressNegative { - color: var(--accent-coral-dark, #b04a2e); - font-weight: 700; -} - -/* ── Semantic status colours (unified) ────────────── */ -.statusGood { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} - -.statusWarn { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} - -.statusBad { - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -/* Charts Section */ -.chartContainer { - width: 100%; - /* Taller on desktop so the trend lines have vertical room to separate - and read clearly. Mobile overrides this to height:auto below (the - max-width:768px query), so this only affects desktop. */ - height: 380px; - position: relative; -} - -/* Detailed Metrics - Compact Grid Layout */ -.metricGroupsGrid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; -} - -.metricGroup { - margin-bottom: 0; -} - -.metricGroupTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin-bottom: 0.5rem; - padding-bottom: 0.375rem; - border-bottom: 1px solid var(--border-color, #e5dfd5); - display: flex; - align-items: center; - gap: 0.375rem; -} - -.metricTable { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.metricRow { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.375rem 0.625rem; - background: var(--bg-secondary, #f3ede4); - border-radius: 4px; -} - -.metricName { - font-size: 0.75rem; - color: var(--text-secondary, #5c564d); -} - -.metricRow .metricValue { - font-size: 0.875rem; - font-weight: 600; - color: var(--accent-teal, #2d7d7d); -} - -/* History Table */ -.tableWrapper { - overflow-x: auto; - max-width: 100%; - margin-top: 0.5rem; - -webkit-overflow-scrolling: touch; -} - -.historicalSubtitle { - font-size: 0.8rem; - color: var(--text-muted, #8a847a); - margin: 1.25rem 0 0.25rem; -} - -.dataTable { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -.dataTable thead { - background: var(--bg-secondary, #f3ede4); -} - -.dataTable th { - padding: 0.625rem 0.75rem; - text-align: left; - font-weight: 600; - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--text-primary, #1a1612); - border-bottom: 2px solid var(--border-color, #e5dfd5); -} - -.dataTable td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border-color, #e5dfd5); - color: var(--text-secondary, #5c564d); -} - -.dataTable tbody tr:last-child td { - border-bottom: none; -} - -.dataTable tbody tr:hover { - background: var(--bg-secondary, #f3ede4); -} - -.yearCell { - font-weight: 600; - color: var(--accent-gold, #c9a227); -} - -/* Ofsted */ -.ofstedHeader { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.ofstedGrade { - display: inline-block; - padding: 0.35rem 0.75rem; - font-size: 1rem; - line-height: 1.4; - font-weight: 700; - border-radius: 6px; - white-space: nowrap; -} - -.ofstedGrade1 { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} -.ofstedGrade2 { - background: rgba(60, 140, 60, 0.12); - color: #2f7a2f; -} -.ofstedGrade3 { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} -.ofstedGrade4 { - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -/* Report Card grade colours (5-level scale, lower = better) */ -.rcGrade1 { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} /* Exceptional */ -.rcGrade2 { - background: rgba(60, 140, 60, 0.12); - color: #2f7a2f; -} /* Strong */ -.rcGrade3 { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} /* Expected standard */ -.rcGrade4 { - background: rgba(249, 115, 22, 0.12); - color: #c2410c; -} /* Needs attention */ -.rcGrade5 { - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} /* Urgent improvement */ - -/* Safeguarding value (used inside a standard metricCard) */ -.safeguardingMet { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 4px; - font-size: 0.8125rem; - font-weight: 600; - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} -.safeguardingNotMet { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 4px; - font-size: 0.8125rem; - font-weight: 700; - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -/* ── Ofsted grade grids (Report Card + OEIF) ── - Uniform, vertically-aligned grade chips. Labels reserve two lines so - single- and double-line labels put their chips on the same baseline; - every chip (Met, Strong, Expected standard, …) shares one font size, - padding and min-height regardless of how many lines its text wraps to. */ -.gradeGrid .metricCard { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 0.85rem 0.75rem; -} -.gradeGrid .metricLabel { - min-height: 2.6em; - margin: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; -} -.gradeGrid .metricValue { - margin-top: auto; - display: inline-flex; - align-items: center; - justify-content: center; - max-width: 100%; - min-height: 2.6em; - padding: 0.3rem 0.7rem; - border-radius: 5px; - font-size: 1rem; - font-weight: 700; - line-height: 1.25; - text-align: center; -} - -.ofstedDisclaimer { - font-size: 0.8rem; - color: var(--text-muted, #8a847a); - font-style: italic; - margin: 0 0 1rem; -} - -.ofstedDate { - font-size: 0.85rem; - color: var(--text-muted, #8a847a); -} - -.ofstedPrevious { - font-size: 0.8125rem; - color: var(--text-muted, #8a847a); - font-style: italic; -} - -.ofstedReportLink { - font-size: 0.8125rem; - color: var(--accent-teal, #2d7d7d); - text-decoration: none; - margin-left: auto; - white-space: nowrap; -} - -.ofstedReportLink:hover { - text-decoration: underline; -} - -/* Admissions badge — uses unified status colours */ -.admissionsBadge { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.3rem 0.75rem; - border-radius: 6px; - font-size: 0.8125rem; - font-weight: 600; - margin-top: 0.75rem; -} - -/* Deprivation dot scale */ -.deprivationDots { - display: flex; - gap: 0.375rem; - margin: 0.75rem 0 0.5rem; - align-items: center; -} - -.deprivationDot { - width: 1.25rem; - height: 1.25rem; - border-radius: 50%; - background: var(--bg-secondary, #f3ede4); - border: 2px solid var(--border-color, #e5dfd5); - flex-shrink: 0; -} - -.deprivationDotFilled { - background: var(--accent-teal, #2d7d7d); - border-color: var(--accent-teal, #2d7d7d); -} - -.deprivationDesc { - font-size: 0.875rem; - color: var(--text-secondary, #5c564d); - line-height: 1.5; - margin: 0; -} - -.deprivationScaleLabel { - display: flex; - justify-content: space-between; - font-size: 0.7rem; - color: var(--text-muted, #8a847a); - margin-top: 0.25rem; -} - -/* Progress note */ -.ofstedAllSame { - font-size: 0.9375rem; - color: var(--text-secondary, #5c564d); - margin: 0.5rem 0 0; - line-height: 1.5; -} - -.ofstedAllSame strong { - color: var(--text-primary, #1a1612); -} - -.progressNote { - margin-top: 0.75rem; - font-size: 0.8rem; - color: var(--text-muted); - font-style: italic; -} - -/* ── Responsive ──────────────────────────────────────── */ -@media (max-width: 768px) { - .headerContent { - flex-direction: column; - gap: 1rem; - } - - .actions { - width: 100%; - } - - .btnAdd, - .btnRemove { - flex: 1; - } - - .schoolName { - font-size: 1.25rem; - } - - /* Pills wrap horizontally instead of stacking — short tokens like - "Manchester" / "Voluntary aided" fit 2 per row instead of 3 full - rows of empty horizontal space. */ - .meta { - flex-direction: row; - flex-wrap: wrap; - gap: 0.375rem; - } - - /* Secondary header info (headteacher, website, pupil count, trust, - contact, area) isn't needed above the fold on phones/tablets, so it's - collapsed by default and revealed on demand via the "Show all details" - link — reclaiming the vertical space so the metrics surface sooner. */ - .detailsToggle { - display: inline-flex; - } - - .headerDetails { - display: none; - } - - .headerDetailsOpen { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .metricsGrid { - grid-template-columns: repeat(2, 1fr); - } - - .metricGroupsGrid { - grid-template-columns: 1fr; - } - - /* PerformanceChart on mobile now stacks: trend banner → chip subtitle → - chip row → canvas → mini-legend → COVID footnote. The container must - flow naturally instead of clipping to a fixed 220px — PerformanceChart's - own .chartWrapper carries the canvas height. */ - .chartContainer { - height: auto; - } - - .dataTable { - font-size: 0.75rem; - } - - .dataTable th, - .dataTable td { - padding: 0.5rem 0.375rem; - } -} - -@media (max-width: 480px) { - .card { - padding: 1rem; - } -} - -/* .heroStatLabel is shared by the SATs / Pupils stat cards below. */ -.heroStatLabel { - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-secondary, #5c564d); -} - -@media (max-width: 640px) { - .heroSummary { - font-size: 1rem; - margin-top: 1rem; - } -} - -/* ── RWM bridge ("Why is combined lower?") ── */ -.rwmBridge { - display: flex; - align-items: flex-start; - gap: 0.75rem; - margin: 0.5rem 0 1.5rem; - padding: 0.9rem 1rem; - background: var(--bg-secondary, #f3ede4); - border-radius: 8px; -} - -.rwmBridgeIcon { - flex-shrink: 0; - width: 22px; - height: 22px; - border-radius: 50%; - background: var(--accent-coral, #e07256); - color: #fff; - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.8rem; - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - margin-top: 0.05rem; -} - -.rwmBridgeBody { - flex: 1; - min-width: 0; -} - -.rwmBridgeText { - font-size: 0.85rem; - color: var(--text-secondary, #5c564d); - line-height: 1.5; -} - -.rwmBridgeText strong { - color: var(--text-primary, #1a1612); - font-weight: 700; -} - -.rwmBridgeMath { - display: flex; - gap: 0.35rem; - margin-top: 0.35rem; - flex-wrap: wrap; - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - color: var(--text-muted, #6d685f); - font-size: 0.78rem; - font-style: italic; - align-items: baseline; -} - -.rwmBridgeMath strong { - color: var(--text-primary, #1a1612); - font-weight: 700; - font-style: normal; -} - -.rwmBridgeMathSep { - opacity: 0.5; -} - -/* ── Progress scores row (below SatsChart) ── */ -.progressScoresRow { - margin-top: 1.25rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color, #e5dfd5); -} - -.progressScoresGrid { - display: flex; - gap: 1.5rem; - flex-wrap: wrap; -} - -.progressScoreItem { - display: flex; - align-items: baseline; - gap: 0.4rem; -} - -.progressScoreLabel { - font-size: 0.78rem; - font-weight: 500; - color: var(--text-muted, #6d685f); -} - -.progressScoreValue { - font-size: 0.9rem; - font-weight: 700; - color: var(--text-primary, #1a1612); - font-variant-numeric: tabular-nums; -} - -/* ── Admissions Q&A list ── */ -/* 2×2 stat tiles — number and label grouped together so the eye - doesn't travel the full card width. Hairline grid via gap + bg. */ -.admissionsTiles { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1px; - background: var(--border-color, #e5dfd5); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 10px; - overflow: hidden; - margin: 0; -} - -.admissionsTile { - background: var(--bg-card, #fff); - padding: 1.15rem 1.25rem; - display: flex; - flex-direction: column; -} - -.admissionsTileNum { - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - font-size: 2.4rem; - font-weight: 700; - line-height: 1; - color: var(--text-primary, #1a1612); - font-variant-numeric: tabular-nums; - margin: 0; - display: flex; - align-items: baseline; - gap: 0.4rem; - flex-wrap: wrap; -} - -.admissionsTileSub { - font-family: var(--font-dm-sans), "DM Sans", sans-serif; - font-size: 0.85rem; - font-weight: 500; - color: var(--text-muted, #6d685f); -} - -.admissionsTileLabel { - margin: 0.55rem 0 0; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted, #6d685f); -} - -.admissionsTileAccent .admissionsTileNum { - color: var(--accent-coral-dark, #c45a3f); -} - -.admissionsVerdict { - margin-top: 0.75rem; - margin-bottom: 0.25rem; -} - -.admissionsVerdictHeadline { - font-family: var(--font-playfair), "Playfair Display", Georgia, serif; - font-size: 1.35rem; - font-weight: 700; - line-height: 1.2; - color: var(--text-primary, #1a1612); -} - -.admissionsVerdictOver { - color: var(--accent-coral-dark, #c45a3f); -} - -.admissionsVerdictUnder { - color: var(--accent-teal, #2d7d7d); -} - -.admissionsVerdictSub { - font-size: 0.8rem; - color: var(--text-muted, #6d685f); - line-height: 1.4; - margin-top: 0.2rem; -} - -@media (max-width: 480px) { - .admissionsTile { - padding: 0.95rem 1rem; - } - - .admissionsTileNum { - font-size: 2rem; - } -} - -/* ── Admissions: header + view toggle ── */ -.admissionsHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - margin-bottom: 1.25rem; -} - -.admissionsHeader .sectionTitle { - margin-bottom: 0; -} - -.admissionsSeg { - display: inline-flex; - background: var(--bg-secondary, #f3ede4); - border-radius: 999px; - padding: 3px; - gap: 2px; - flex: none; -} - -.admissionsSeg button { - appearance: none; - border: none; - background: none; - cursor: pointer; - font: inherit; - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-muted, #6d685f); - padding: 0.4rem 0.9rem; - border-radius: 999px; - white-space: nowrap; - transition: background 0.15s ease, color 0.15s ease; -} - -.admissionsSeg button[aria-pressed="true"] { - background: var(--bg-card, #fff); - color: var(--text-primary, #1a1612); - box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); -} - -.admissionsSeg button:hover[aria-pressed="false"] { - color: var(--text-secondary, #5c564d); -} - -/* Stack both views in one grid cell so the card sizes to the taller view — - toggling modes never shifts layout. */ -.admissionsViewport { - display: grid; -} - -.admissionsViewYear, -.admissionsViewTrend { - grid-area: 1 / 1; -} - -.admissionsViewYear[hidden], -.admissionsViewTrend[hidden] { - display: block; - visibility: hidden; - pointer-events: none; -} - -/* The tile grid fills the height reserved by the taller (trend) view. */ -.admissionsViewYear { - display: flex; - flex-direction: column; -} - -.admissionsViewYear .admissionsTiles { - flex: 1; - grid-template-rows: 1fr 1fr; -} - -.admissionsChartCap { - font-size: 0.8125rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted, #6d685f); - margin-bottom: 0.5rem; -} - -.admissionsTrendSummary { - font-size: 1rem; - color: var(--text-secondary, #5c564d); - margin: 1.25rem 0 0; - padding-top: 1.1rem; - border-top: 1px solid var(--border-color, #e5dfd5); - line-height: 1.5; -} - -.admissionsTrendSummary strong { - color: var(--text-primary, #1a1612); -} - -/* ── History accordion ── */ -.historyDisclosure { - margin-top: 1rem; -} - -.historyToggle { - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-muted, #6d685f); - cursor: pointer; - padding: 0.5rem 0; - list-style: none; - display: flex; - align-items: center; - gap: 0.4rem; -} - -.historyToggle::-webkit-details-marker { - display: none; -} - -.historyToggle::before { - content: "▸"; - display: inline-block; - transition: transform 0.2s ease; - font-size: 0.7rem; -} - -.historyDisclosure[open] > .historyToggle::before { - transform: rotate(90deg); -} - -/* 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; -} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx deleted file mode 100644 index cb5da31..0000000 --- a/nextjs-app/components/SchoolDetailView.tsx +++ /dev/null @@ -1,1287 +0,0 @@ -/** - * SchoolDetailView Component - * Displays comprehensive school information with performance charts - */ - -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import dynamic from 'next/dynamic'; -import { useComparison } from '@/hooks/useComparison'; -import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap'; -import { MetricTooltip } from './MetricTooltip'; -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, buildNavItems } from '@/lib/schoolSections'; -import { DeltaChip } from './DeltaChip'; -import { SpecialSchoolNote } from './SpecialSchoolNote'; -import { summariseAdmissions } from '@/lib/compareLogic'; - -const PerformanceChart = dynamic( - () => import('./PerformanceChart').then((m) => m.PerformanceChart), - { ssr: false }, -); -const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false }); -const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false }); -import { track, getNavigationSource } from '@/lib/analytics'; -import styles from './SchoolDetailView.module.css'; - -const OFSTED_LABELS: Record = { - 1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate', -}; - -const RC_LABELS: Record = { - 1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement', -}; - -const RC_CATEGORIES = [ - { key: 'rc_inclusion' as const, label: 'Inclusion' }, - { key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' }, - { key: 'rc_achievement' as const, label: 'Achievement' }, - { key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' }, - { key: 'rc_personal_development' as const, label: 'Personal Development' }, - { key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' }, - { key: 'rc_early_years' as const, label: 'Early Years' }, - { key: 'rc_sixth_form' as const, label: 'Sixth Form' }, -]; - - -function progressClass(val: number | null | undefined): string { - if (val == null) return ''; - if (val > 0) return styles.progressPositive; - if (val < 0) return styles.progressNegative; - return ''; -} - -interface SchoolDetailViewProps { - 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; -} - -export function SchoolDetailView({ - schoolInfo, yearlyData, absenceData, - ofsted, census, admissions, admissionsHistory, deprivation, finance, - nationalAvg, -}: SchoolDetailViewProps) { - const router = useRouter(); - const { addSchool, removeSchool, isSelected } = useComparison(); - const isInComparison = isSelected(schoolInfo.urn); - - const [activeSection, setActiveSection] = useState(''); - const [admissionsView, setAdmissionsView] = useState<'year' | 'trend'>('year'); - // Trend toggle only appears with ≥2 years carrying an offer rate. - const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; - const showAdmissionsTrend = admissionsOfferYears >= 2; - // Banded interpretation of the first-choice offer rate ("More than half of - // first choices missed out" etc.) — the same banding the compare screen - // uses, so a low offer rate reads as how severe it actually is. - const admissionsSummary = summariseAdmissions(admissions); - // Only the section links scroll horizontally; Back and "All" stay pinned. - const sectionLinksRef = useRef(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(null); - const [heroCtaVisible, setHeroCtaVisible] = useState(true); - // Hero map — the "View on map" link opens its fullscreen view. - const heroMapRef = useRef(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).`; - }; - - const navItems = buildNavItems(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 = {}; - - 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 ( -
- {/* 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). */} - - - {/* Header — the location map band blends down into the school title. */} -
- {hasLocation && ( - - )} -
-
-

{schoolInfo.school_name}

-
- {schoolInfo.local_authority && ( - {schoolInfo.local_authority} - )} - {schoolInfo.school_type && ( - {schoolInfo.school_type} - )} - {isAllThrough && ( - All-through (primary & secondary) - )} - {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( - {schoolInfo.gender}'s school - )} - {schoolInfo.age_range && ( - {formatAgeRange(schoolInfo.age_range)} - )} - {schoolInfo.nursery_provision && ( - Nursery - )} - {schoolInfo.has_sixth_form && ( - Sixth form - )} -
- {isProposedToClose(schoolInfo) && ( -
- ⚠ Proposed to close — this school is proposed for closure, - check with the local authority before applying. -
- )} - {schoolInfo.address && ( -

- {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} - {hasLocation && ( - <> - {' · '} - - - )} -

- )} - -
- {schoolInfo.headteacher_name && ( - - Headteacher: {schoolInfo.headteacher_name} - - )} - {schoolInfo.website && ( - - - School website ↗ - - - )} - {(() => { - const total = census?.total_pupils ?? latestResults?.total_pupils ?? null; - if (total == null) return null; - return ( - - Pupils: {total.toLocaleString()} - {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} - - ); - })()} - {schoolInfo.trust_name && ( - - Part of {schoolInfo.trust_name} - - )} - {schoolInfo.telephone && ( - - Phone:{' '} - - {schoolInfo.telephone} - - - )} - {schoolInfo.religious_denomination && ( - - Religious character:{' '} - {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) - ? 'None' - : schoolInfo.religious_denomination} - - )} - {schoolInfo.county && ( - - County: {schoolInfo.county} - - )} - {schoolInfo.parliamentary_constituency && ( - - Constituency: {schoolInfo.parliamentary_constituency} - - )} -
-
-
- -
-
-
- - {/* Sticky Section Navigation — docks under the global header */} - - - {/* Ofsted Rating / Report Card */} - {ofsted && ( -
-

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

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

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

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

- {ofsted.grade_source === 'ungraded_carried_forward' - ? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.' - : 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'} -

- {oeifAllSameGrade ? ( -

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

- ) : ( -
- {oeifAreas.map(({ label, value }) => ( -
-
{label}
-
- {OFSTED_LABELS[value]} -
-
- ))} -
- )} - - )} -
- )} - - {/* Results Section (SATs for primary, GCSEs for secondary) */} - {hasAnyResults && latestResults && ( -
-

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

-

- {isSpecial - ? (isSecondary - ? 'GCSE results for Year 11 pupils.' - : 'End-of-primary-school tests taken by Year 6 pupils.') - : isAllThrough - ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.' - : isSecondary - ? 'GCSE results for Year 11 pupils. England averages shown for comparison.' - : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'} -

- - {/* Explains up front why the England comparison is dropped below, so - a 0% headline never reads as a failing grade against a benchmark - that doesn't fit. Type-aware copy (special vs PRU vs AP). */} - - - {/* ── Primary / KS2 content ── */} - {hasKS2Results && ( - <> - {isAllThrough && ( -

Primary — KS2 SATs (Year 6)

- )} -
- {latestResults.rwm_expected_pct !== null && ( -
-
- Reading, Writing & Maths combined - -
-
- {formatPercentage(latestResults.rwm_expected_pct)} - {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( - - )} -
- {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( -
England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
- )} -
- )} - {latestResults.rwm_high_pct !== null && ( -
-
- Exceeding expected level (Reading, Writing & Maths) - -
-
- {formatPercentage(latestResults.rwm_high_pct)} - {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( - - )} -
- {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( -
England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
- )} -
- )} -
- - {!suppressKs2Comparison && - latestResults.rwm_expected_pct != null && - latestResults.reading_expected_pct != null && - latestResults.writing_expected_pct != null && - latestResults.maths_expected_pct != null && ( -
- -
-
- Why is combined lower? A pupil is only counted if they met the bar in{' '} - all three subjects. Some passed reading but not writing; some passed writing but not maths. -
-
- Reading {latestResults.reading_expected_pct.toFixed(0)}% - · - Writing {latestResults.writing_expected_pct.toFixed(0)}% - · - Maths {latestResults.maths_expected_pct.toFixed(0)}% - - All three {latestResults.rwm_expected_pct.toFixed(0)}% -
-
-
- )} - - {/* All-zero placeholder rows (special / suppressed) would render as - three empty bars against the national markers — misleading, so - skip the chart. For a special school with some non-zero - subjects, keep the bars but drop the national markers. */} - {!ks2Placeholder && ( - - )} - - {/* Progress scores row */} - {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && ( -
-

Progress Scores

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

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

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

Secondary — GCSEs (Year 11)

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

- English Baccalaureate (EBacc) - -

-
- {latestResults.ebacc_entry_pct !== null && ( -
- Pupils entered for EBacc - {formatPercentage(latestResults.ebacc_entry_pct)} -
- )} - {latestResults.ebacc_standard_pass_pct !== null && ( -
- - EBacc Grade 4+ - - - {formatPercentage(latestResults.ebacc_standard_pass_pct)} -
- )} - {latestResults.ebacc_strong_pass_pct !== null && ( -
- - EBacc Grade 5+ - - - {formatPercentage(latestResults.ebacc_strong_pass_pct)} -
- )} -
- - )} - - )} -
- )} - - {/* Admissions */} - {admissions && ( -
-
-

- Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`} -

- {showAdmissionsTrend && ( -
- - -
- )} -
- {/* All-through admissions data covers a single entry point (usually the - Year 7 secondary intake), not reception — say so, or a parent could - read these as the whole-school figures. */} - {isAllThrough && admissions.school_phase && ( -

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

- )} - -
- {/* This-year Q&A */} - - - {/* Multi-year trend */} - {showAdmissionsTrend && ( - - )} -
-
- )} - - {/* Pupils & Inclusion */} - {hasInclusionData && ( -
-

Pupils & Inclusion

-
- {latestResults?.disadvantaged_pct != null && ( -
-
Eligible for pupil premium
-
- {formatPercentage(latestResults.disadvantaged_pct)} - {primaryAvg.disadvantaged_pct != null && ( - - )} -
-
Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}
-
- )} - {latestResults?.eal_pct != null && ( -
-
- English as an additional language - -
-
- {formatPercentage(latestResults.eal_pct)} - {primaryAvg.eal_pct != null && ( - - )} -
- {primaryAvg.eal_pct != null && ( -
England avg: {primaryAvg.eal_pct.toFixed(0)}%
- )} -
- )} - {latestResults?.sen_support_pct != null && ( -
-
- Pupils receiving SEN support - -
-
- {formatPercentage(latestResults.sen_support_pct)} - {primaryAvg.sen_support_pct != null && ( - - )} -
- {primaryAvg.sen_support_pct != null && ( -
England avg: {primaryAvg.sen_support_pct.toFixed(0)}%
- )} -
- )} - {hasGenderSplit && (() => { - const female = census!.female_pupils!; - const male = census!.male_pupils!; - const girlsPct = Math.round((female / (female + male)) * 100); - const boysPct = 100 - girlsPct; - return ( -
-
Boys and girls
-
- {girlsPct}% - girls - · - {boysPct}% - boys -
-
- - -
-
- {female.toLocaleString()} girls, {male.toLocaleString()} boys -
-
- ); - })()} -
-
- )} - - {/* Results Over Time (merged: chart + historical table) */} - {yearlyData.length > 0 && ( -
-

Results Over Time

- {isAllThrough ? ( - // All-through: KS2 and KS4 trends are on different scales and have - // different gap stories, so render them as two stacked charts - // rather than crowding 8+ series onto one axis. - <> - {hasKS2Results && ( - <> -

Primary — KS2 SATs

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

Secondary — GCSEs

-
- -
- - )} - - ) : ( -
- -
- )} - {yearlyData.length > 1 && ( -
- View raw year-by-year data -
- - - - - {isAllThrough ? ( - <> - - - - - - - ) : isSecondary ? ( - <> - - - - - - ) : ( - <> - - - - - - - )} - - - - {yearlyData.map((result) => ( - - - {isAllThrough ? ( - <> - - - - - - - ) : isSecondary ? ( - <> - - - - - - ) : ( - <> - - - - - - - )} - - ))} - -
YearRWM (expected %)Exceeding (%)Attainment 8Progress 8English & Maths Grade 4+Attainment 8Progress 8English & Maths Grade 4+English & Maths Grade 5+Reading, Writing & Maths (expected %)Exceeding expected (%)Reading ProgressWriting ProgressMaths Progress
{formatAcademicYear(result.year)}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}
-
-
- )} -
- )} - {/* School Life */} - {hasSchoolLife && ( -
-

School Life

-
- {absenceData?.overall_absence_rate != null && ( -
-
- Days missed (overall absence) - -
-
{formatPercentage(absenceData.overall_absence_rate)}
- {primaryAvg.overall_absence_pct != null && ( -
England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%
- )} -
- )} - {absenceData?.persistent_absence_rate != null && ( -
-
- Regularly missing school - -
-
{formatPercentage(absenceData.persistent_absence_rate)}
- {primaryAvg.persistent_absence_pct != null && ( -
England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%
- )} -
- )} -
-
- )} - - {/* Local Area Context */} - {hasDeprivation && deprivation && ( -
-

- Local Area Context - -

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

{deprivationDesc(deprivation.idaci_decile!)}

-
- )} - - {/* Finances */} - {hasFinance && finance && ( -
-

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

-

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

-
-
-
Total spend per pupil per year
-
£{Math.round(finance.per_pupil_spend!).toLocaleString()}
-
How much the school has to spend on each pupil annually
-
- {finance.teacher_cost_pct != null && ( -
-
Share of budget spent on teachers
-
{finance.teacher_cost_pct.toFixed(1)}%
-
- )} - {finance.staff_cost_pct != null && ( -
-
Share of budget spent on all staff
-
{finance.staff_cost_pct.toFixed(1)}%
-
- )} -
-
- )} - -
- ); -} diff --git a/nextjs-app/components/SecondarySchoolDetailView.module.css b/nextjs-app/components/SecondarySchoolDetailView.module.css deleted file mode 100644 index cf57443..0000000 --- a/nextjs-app/components/SecondarySchoolDetailView.module.css +++ /dev/null @@ -1,1185 +0,0 @@ -/* SecondarySchoolDetailView — borrows heavily from SchoolDetailView.module.css */ - -.container { - 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 ──────────────────────────────────────────── */ -.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; -} - -@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; - } -} - -/* 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.15; - font-family: var(--font-playfair), "Playfair Display", serif; - overflow-wrap: break-word; -} - -.badges { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; - margin-bottom: 0.5rem; -} - -.badge { - font-size: 0.8125rem; - color: var(--text-secondary, #5c564d); - padding: 0.125rem 0.5rem; - background: var(--bg-secondary, #f3ede4); - border-radius: 3px; -} - -.badgeSelective { - background: rgba(180, 120, 0, 0.1); - color: #8a6200; -} - -.badgeFaith { - background: rgba(45, 125, 125, 0.1); - color: var(--accent-teal, #2d7d7d); -} - -.address { - font-size: 0.875rem; - color: var(--text-muted, #8a847a); - margin: 0 0 0.75rem; - overflow-wrap: break-word; -} - -.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; -} - -.btnAdd, -.btnRemove { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 600; - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; -} - -.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; -} - -/* ── Tab Navigation (sticky) ─────────────────────────── */ -.tabNav { - position: sticky; - top: 4rem; - 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 1rem; - margin-bottom: 1rem; - overflow-x: auto; - white-space: nowrap; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); -} - -.tabNav::-webkit-scrollbar { - display: none; -} - -.tabNavInner { - display: inline-flex; - gap: 0.25rem; - align-items: center; -} - -.backBtn { - display: inline-flex; - align-items: center; - 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; - margin-right: 0.25rem; -} - -.backBtn:hover { - background: var(--bg-secondary, #f3ede4); - border-color: var(--accent-coral, #e07256); -} - -.tabNavDivider { - width: 1px; - height: 1rem; - background: var(--border-color, #e5dfd5); - margin: 0 0.25rem; - flex-shrink: 0; -} - -.tabBtn { - display: inline-block; - padding: 0.3rem 0.75rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--text-secondary, #5c564d); - background: none; - border: none; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; - text-decoration: none; -} - -.tabBtn:hover { - background: var(--bg-secondary, #f3ede4); - color: var(--text-primary, #1a1612); -} - -.tabBtnActive { - background: var(--accent-coral-dark, #b04a2e); - color: white; - font-weight: 600; -} - -.tabBtnActive:hover { - background: var(--accent-coral-darker, #9c3f26); - color: white; -} - -/* ── Card ────────────────────────────────────────────── */ -.card { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 10px; - padding: 1.25rem 1.5rem; - margin-bottom: 1rem; - box-shadow: var(--shadow-soft); - scroll-margin-top: 6rem; -} - -/* ── Section Title ───────────────────────────────────── */ -.sectionTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin-bottom: 0.875rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--border-color, #e5dfd5); - font-family: var(--font-playfair), "Playfair Display", serif; - display: flex; - align-items: center; - gap: 0.375rem; - flex-wrap: wrap; - overflow-wrap: break-word; - min-width: 0; -} - -.sectionTitle::before { - content: ""; - display: inline-block; - width: 3px; - height: 1em; - background: var(--accent-coral, #e07256); - border-radius: 2px; - flex-shrink: 0; -} - -.sectionSubtitle { - font-size: 0.85rem; - color: var(--text-muted, #8a847a); - margin: -0.5rem 0 1rem; -} - -.subSectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-secondary, #5c564d); - margin: 1.25rem 0 0.75rem; -} - -/* ── Progress 8 suspension banner ───────────────────── */ -.p8Banner { - background: rgba(180, 120, 0, 0.1); - border: 1px solid rgba(180, 120, 0, 0.3); - color: #8a6200; - border-radius: 6px; - padding: 0.625rem 0.875rem; - font-size: 0.825rem; - margin-bottom: 1rem; - line-height: 1.5; -} - -/* ── Metrics Grid & Cards ────────────────────────────── */ -.metricsGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.75rem; -} - -.metricCard { - background: var(--bg-secondary, #f3ede4); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 6px; - padding: 0.75rem; - text-align: center; -} - -.metricLabel { - font-size: 0.6875rem; - color: var(--text-muted, #8a847a); - margin-bottom: 0.25rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.metricValue { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary, #1a1612); - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; - overflow-wrap: break-word; - word-break: break-word; -} - -.metricHint { - font-size: 0.7rem; - color: var(--text-muted, #8a847a); - margin-top: 0.3rem; - font-style: italic; -} - -/* ── Progress score colours ──────────────────────────── */ -.progressPositive { - color: var(--accent-teal, #2d7d7d); - font-weight: 700; -} - -.progressNegative { - color: var(--accent-coral-dark, #b04a2e); - font-weight: 700; -} - -/* ── Status colours ──────────────────────────────────── */ -.statusGood { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} - -.statusWarn { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} - -/* ── Metric table (row-based) ────────────────────────── */ -.metricTable { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.metricRow { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.375rem 0.625rem; - background: var(--bg-secondary, #f3ede4); - border-radius: 4px; -} - -.metricName { - font-size: 0.75rem; - color: var(--text-secondary, #5c564d); -} - -.metricRow .metricValue { - font-size: 0.875rem; - font-weight: 600; - color: var(--accent-teal, #2d7d7d); -} - -/* ── Charts & Map ────────────────────────────────────── */ -.chartContainer { - width: 100%; - /* Taller on desktop so the trend lines have vertical room to separate - and read clearly. Mobile overrides this to height:auto below (the - max-width:768px query), so this only affects desktop. */ - height: 380px; - position: relative; -} - -/* ── History table ───────────────────────────────────── */ -.tableWrapper { - overflow-x: auto; - margin-top: 0.5rem; -} - -.historicalSubtitle { - font-size: 0.8rem; - color: var(--text-muted, #8a847a); - margin: 1.25rem 0 0.25rem; -} - -.dataTable { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -.dataTable thead { - background: var(--bg-secondary, #f3ede4); -} - -.dataTable th { - padding: 0.625rem 0.75rem; - text-align: left; - font-weight: 600; - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--text-primary, #1a1612); - border-bottom: 2px solid var(--border-color, #e5dfd5); -} - -.dataTable td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border-color, #e5dfd5); - color: var(--text-secondary, #5c564d); -} - -.dataTable tbody tr:last-child td { - border-bottom: none; -} - -.dataTable tbody tr:hover { - background: var(--bg-secondary, #f3ede4); -} - -.yearCell { - font-weight: 600; - color: var(--accent-gold, #c9a227); -} - -/* ── Ofsted ──────────────────────────────────────────── */ -.ofstedHeader { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.ofstedGrade { - display: inline-block; - padding: 0.35rem 0.75rem; - font-size: 1rem; - line-height: 1.4; - font-weight: 700; - border-radius: 6px; - white-space: nowrap; -} - -.ofstedGrade1 { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} -.ofstedGrade2 { - background: rgba(60, 140, 60, 0.12); - color: #2f7a2f; -} -.ofstedGrade3 { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} -.ofstedGrade4 { - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -.rcGrade1 { - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} -.rcGrade2 { - background: rgba(60, 140, 60, 0.12); - color: #2f7a2f; -} -.rcGrade3 { - background: var(--accent-gold-bg); - color: var(--accent-gold-text, #7a6800); -} -.rcGrade4 { - background: rgba(249, 115, 22, 0.12); - color: #c2410c; -} -.rcGrade5 { - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -.safeguardingMet { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 4px; - font-size: 0.8125rem; - font-weight: 600; - background: var(--accent-teal-bg); - color: var(--accent-teal, #2d7d7d); -} - -.safeguardingNotMet { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 4px; - font-size: 0.8125rem; - font-weight: 700; - background: var(--accent-coral-bg); - color: var(--accent-coral-dark, #b04a2e); -} - -/* ── Ofsted grade grids (Report Card + OEIF) ── - Uniform, vertically-aligned grade chips. Labels reserve two lines so - single- and double-line labels put their chips on the same baseline; - every chip (Met, Strong, Expected standard, …) shares one font size, - padding and min-height regardless of how many lines its text wraps to. */ -.gradeGrid .metricCard { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 0.85rem 0.75rem; -} -.gradeGrid .metricLabel { - min-height: 2.6em; - margin: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; -} -.gradeGrid .metricValue { - margin-top: auto; - display: inline-flex; - align-items: center; - justify-content: center; - max-width: 100%; - min-height: 2.6em; - padding: 0.3rem 0.7rem; - border-radius: 5px; - font-size: 1rem; - font-weight: 700; - line-height: 1.25; - text-align: center; -} - -.ofstedDisclaimer { - font-size: 0.8rem; - color: var(--text-muted, #8a847a); - font-style: italic; - margin: 0 0 1rem; -} - -.ofstedDate { - font-size: 0.85rem; - color: var(--text-muted, #8a847a); -} - -.ofstedPrevious { - font-size: 0.8125rem; - color: var(--text-muted, #8a847a); - font-style: italic; -} - -.ofstedReportLink { - font-size: 0.8125rem; - color: var(--accent-teal, #2d7d7d); - text-decoration: none; - margin-left: auto; - white-space: nowrap; -} - -.ofstedReportLink:hover { - text-decoration: underline; -} - -/* ── Admissions ──────────────────────────────────────── */ -.admissionsTypeBadge { - border-radius: 6px; - padding: 0.5rem 0.875rem; - font-size: 0.8125rem; - margin-bottom: 1rem; - line-height: 1.5; -} - -.admissionsSelective { - background: rgba(180, 120, 0, 0.1); - color: #8a6200; - border: 1px solid rgba(180, 120, 0, 0.25); -} - -.admissionsFaith { - background: rgba(45, 125, 125, 0.08); - color: var(--accent-teal, #2d7d7d); - border: 1px solid rgba(45, 125, 125, 0.2); -} - -.admissionsBadge { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.3rem 0.75rem; - border-radius: 6px; - font-size: 0.8125rem; - font-weight: 600; - margin-top: 0.75rem; -} - -.sixthFormNote { - margin-top: 1rem; - padding: 0.625rem 0.875rem; - background: var(--bg-secondary, #f3ede4); - border-radius: 6px; - font-size: 0.825rem; - color: var(--text-secondary, #5c564d); - border-left: 3px solid var(--accent-teal, #2d7d7d); -} - -/* ── Deprivation ─────────────────────────────────────── */ -.deprivationDots { - display: flex; - gap: 0.375rem; - margin: 0.75rem 0 0.5rem; - align-items: center; -} - -.deprivationDot { - width: 1.25rem; - height: 1.25rem; - border-radius: 50%; - background: var(--bg-secondary, #f3ede4); - border: 2px solid var(--border-color, #e5dfd5); - flex-shrink: 0; -} - -.deprivationDotFilled { - background: var(--accent-teal, #2d7d7d); - border-color: var(--accent-teal, #2d7d7d); -} - -.deprivationDesc { - font-size: 0.875rem; - color: var(--text-secondary, #5c564d); - line-height: 1.5; - margin: 0; -} - -.deprivationScaleLabel { - display: flex; - justify-content: space-between; - font-size: 0.7rem; - color: var(--text-muted, #8a847a); - margin-top: 0.25rem; -} - -/* ── Ofsted all-same collapse ────────────────────────── */ -.ofstedAllSame { - font-size: 0.9375rem; - color: var(--text-secondary, #5c564d); - margin: 0.5rem 0 0; - line-height: 1.5; -} - -.ofstedAllSame strong { - color: var(--text-primary, #1a1612); -} - -/* .heroStatLabel is shared by the GCSE / Pupils stat cards below. */ -.heroStatLabel { - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-secondary, #5c564d); -} - -/* ── GCSE hero stat cards (mirrors primary heroStatCard) ─ */ -.heroStatGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 0.85rem; - margin-bottom: 0.25rem; -} - -.heroStatCard { - background: rgba(45, 125, 125, 0.1); - border: 1px solid rgba(45, 125, 125, 0.2); - border-radius: 12px; - padding: 1rem 1.1rem; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.heroStatCard .heroStatLabel { - font-size: 0.6rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted, #6d685f); -} - -.heroStatCard .heroStatValue { - font-family: var(--font-playfair), "Playfair Display", serif; - font-size: 2.1rem; - font-weight: 700; - line-height: 1; - color: var(--accent-teal, #2d7d7d); - display: flex; - align-items: baseline; - gap: 0.4rem; - flex-wrap: wrap; -} - -.heroStatCard .heroStatHint { - font-size: 0.7rem; - color: var(--text-muted, #6d685f); - font-style: normal; - margin-top: 0; -} - -/* Gender split — attached beneath the Total pupils stat value */ -.genderBar { - display: flex; - height: 4px; - border-radius: 999px; - overflow: hidden; - background: rgba(0, 0, 0, 0.08); - margin-top: 0.45rem; -} - -.genderBarGirls { - background: #a04a68; -} - -.genderBarBoys { - background: var(--accent-teal, #2d7d7d); -} - -.genderSplitHint { - font-size: 0.7rem; - color: var(--text-muted, #6d685f); - margin-top: 0.35rem; - font-weight: 500; -} - -.genderSplitGirls { - color: #a04a68; - font-weight: 600; -} - -.genderSplitBoys { - color: var(--accent-teal, #2d7d7d); - font-weight: 600; -} - -.genderSplitSep { - color: var(--border-color, #e5dfd5); -} - -/* ── Attainment 8 visual bar ─────────────────────────── */ -.att8Viz { - margin: 1.25rem 0 0.5rem; -} - -.att8VizLabel { - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-muted, #6d685f); - margin-bottom: 0.5rem; -} - -.att8VizTrack { - position: relative; - height: 14px; - background: rgba(45, 125, 125, 0.08); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 4px; - overflow: visible; -} - -.att8VizFill { - height: 100%; - background: var(--accent-teal, #2d7d7d); - border-radius: 4px 0 0 4px; - transition: width 0.6s ease; -} - -.att8VizNatLine { - position: absolute; - top: -4px; - bottom: -4px; - width: 2px; - background: var(--accent-coral, #e07256); - border-radius: 2px; - z-index: 2; -} - -.att8VizNatPill { - position: absolute; - top: -20px; - transform: translateX(-50%); - background: var(--accent-coral-dark, #b04a2e); - color: #fff; - font-size: 0.6rem; - font-weight: 700; - padding: 0.1rem 0.3rem; - border-radius: 3px; - white-space: nowrap; -} - -.att8VizTicks { - display: flex; - justify-content: space-between; - margin-top: 0.25rem; - font-size: 0.6rem; - color: var(--text-muted, #6d685f); -} - -/* ── Progress 8 number line ──────────────────────────── */ -.p8Viz { - margin: 1.25rem 0 0.5rem; -} - -.p8VizLabel { - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-muted, #6d685f); - margin-bottom: 0.5rem; -} - -.p8VizTrack { - position: relative; - height: 14px; - background: rgba(45, 125, 125, 0.06); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 4px; - overflow: visible; -} - -.p8VizCi { - position: absolute; - top: 0; - bottom: 0; - background: rgba(45, 125, 125, 0.18); - border-radius: 3px; -} - -.p8VizZero { - position: absolute; - top: -4px; - bottom: -4px; - width: 2px; - background: var(--border-color, #e5dfd5); - z-index: 1; -} - -.p8VizDot { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent-teal, #2d7d7d); - border: 2px solid white; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); - z-index: 3; -} - -.p8VizDotNeg { - background: var(--accent-coral, #e07256); -} - -.p8VizTicks { - display: flex; - justify-content: space-between; - margin-top: 0.25rem; - font-size: 0.6rem; - color: var(--text-muted, #6d685f); -} - -/* ── History accordion ───────────────────────────────── */ -.historyDisclosure { - margin-top: 1rem; -} - -.historyToggle { - list-style: none; - cursor: pointer; - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-muted, #6d685f); - padding: 0.5rem 0; - display: flex; - align-items: center; - gap: 0.375rem; - user-select: none; -} - -.historyToggle::-webkit-details-marker { - display: none; -} - -.historyToggle::before { - content: "▶"; - font-size: 0.6rem; - transition: transform 0.2s ease; -} - -.historyDisclosure[open] .historyToggle::before { - transform: rotate(90deg); -} - -/* ── Responsive ──────────────────────────────────────── */ -@media (max-width: 768px) { - .header { - padding: 1rem; - } - - .headerContent { - flex-direction: column; - gap: 1rem; - } - - .actions { - width: 100%; - } - - .btnAdd, - .btnRemove { - flex: 1; - } - - .schoolName { - word-break: break-word; - } - - .badges { - gap: 0.25rem; - } - - .badge { - font-size: 0.75rem; - padding: 0.1rem 0.375rem; - } - - /* Collapsed below the fold on phones/tablets; revealed via "Show all - details" so the metrics surface sooner. */ - .detailsToggle { - display: inline-flex; - } - - .headerDetails { - display: none; - } - - .headerDetailsOpen { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .metricsGrid { - grid-template-columns: repeat(2, 1fr); - } - - .metricValue { - font-size: 1rem; - } - - .heroStatGrid { - grid-template-columns: 1fr; - } - - .heroStatCard .heroStatValue { - font-size: 1.85rem; - } - - /* On mobile let the chart container flow naturally — PerformanceChart's - own .chartWrapper carries the definite canvas height (220px) plus the - chip strip above it. A fixed 220px here double-constrained the two and - clipped the chips onto the plot area. */ - .chartContainer { - height: auto; - } - - .dataTable { - font-size: 0.75rem; - } - - .dataTable th, - .dataTable td { - padding: 0.5rem 0.375rem; - } - - .card { - padding: 1rem; - } - - .sectionTitle { - font-size: 1rem; - } - - .ofstedReportLink { - margin-left: 0; - display: block; - margin-top: 0.25rem; - } - - .admissionsTypeBadge { - font-size: 0.75rem; - } -} - -@media (max-width: 480px) { - .metricsGrid { - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - } - - .metricCard { - padding: 0.5rem; - } - - .metricLabel { - font-size: 0.625rem; - } - - .card { - padding: 0.75rem; - } -} - -/* 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; -} diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx deleted file mode 100644 index ea6a174..0000000 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ /dev/null @@ -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 = { - 1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate', -}; - -const RC_LABELS: Record = { - 1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement', -}; - -const RC_CATEGORIES = [ - { key: 'rc_inclusion' as const, label: 'Inclusion' }, - { key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' }, - { key: 'rc_achievement' as const, label: 'Achievement' }, - { key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' }, - { key: 'rc_personal_development' as const, label: 'Personal Development' }, - { key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' }, - { key: 'rc_early_years' as const, label: 'Early Years' }, - { key: 'rc_sixth_form' as const, label: 'Sixth Form' }, -]; - -function progressClass(val: number | null | undefined, modStyles: Record): 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(null); - const { addSchool, removeSchool, isSelected } = useComparison(); - const isInComparison = isSelected(schoolInfo.urn); - - const [activeSection, setActiveSection] = useState(''); - // 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 = {}; - 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 ( -
- {/* Standalone back link, above the header — returns wherever the user - came from. Scrolls away; the sticky bar keeps a "back to top" control. */} - - - {/* ── Header — the location map band blends into the school title ── */} -
- {hasLocation && ( - - )} -
-
-

{schoolInfo.school_name}

-
- {schoolInfo.school_type && ( - {schoolInfo.school_type} - )} - {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( - {schoolInfo.gender}'s school - )} - {schoolInfo.age_range && ( - {formatAgeRange(schoolInfo.age_range)} - )} - {schoolInfo.nursery_provision && ( - Nursery - )} - {hasSixthForm && ( - Sixth form - )} - {admissionsTag && ( - - {admissionsTag} - - )} -
- {isProposedToClose(schoolInfo) && ( -
- ⚠ Proposed to close — this school is proposed for closure, - check with the local authority before applying. -
- )} - {schoolInfo.address && ( -

- {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} - {hasLocation && ( - <> - {' · '} - - - )} -

- )} - -
- {schoolInfo.headteacher_name && ( - - Headteacher: {schoolInfo.headteacher_name} - - )} - {schoolInfo.website && ( - - - School website ↗ - - - )} - {(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && ( - - Pupils: {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()} - {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} - - )} - {schoolInfo.trust_name && ( - - Part of {schoolInfo.trust_name} - - )} - {schoolInfo.telephone && ( - - Phone:{' '} - - {schoolInfo.telephone} - - - )} - {schoolInfo.religious_denomination && ( - - Religious character:{' '} - {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) - ? 'None' - : schoolInfo.religious_denomination} - - )} - {schoolInfo.county && ( - - County: {schoolInfo.county} - - )} - {schoolInfo.parliamentary_constituency && ( - - Constituency: {schoolInfo.parliamentary_constituency} - - )} -
-
-
- -
-
-
- - {/* ── Sticky section navigation ─────────────────────── */} - - - {/* ── Ofsted ─────────────────────────────────────── */} - {ofsted && ( -
-

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

- {isReportCard ? ( - <> -

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

-
- {ofsted.rc_safeguarding_met != null && ( -
-
Safeguarding
-
- {ofsted.rc_safeguarding_met ? 'Met' : 'Not met'} -
-
- )} - {RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => { - const value = ofsted[key] as number | null; - return value != null ? ( -
-
{label}
-
- {RC_LABELS[value]} -
-
- ) : null; - })} -
- - ) : ofsted.overall_effectiveness ? ( - <> -
- - {OFSTED_LABELS[ofsted.overall_effectiveness]} - - {ofsted.previous_overall != null && - ofsted.previous_overall !== ofsted.overall_effectiveness && ( - - Previously: {OFSTED_LABELS[ofsted.previous_overall]} - - )} -
-

- {ofsted.grade_source === 'ungraded_carried_forward' - ? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.' - : 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'} -

- {oeifAllSameGrade ? ( -

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

- ) : ( -
- {oeifAreas.map(({ label, value }) => ( -
-
{label}
-
- {OFSTED_LABELS[value]} -
-
- ))} -
- )} - - ) : ( - <> -

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

-
- {[ - { label: 'Quality of Education', value: ofsted.quality_of_education }, - { label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes }, - { label: 'Personal Development', value: ofsted.personal_development }, - { label: 'Leadership & Management', value: ofsted.leadership_management }, - ].filter(({ value }) => value != null).map(({ label, value }) => ( -
-
{label}
-
- {OFSTED_LABELS[value!]} -
-
- ))} -
- - )} -
- )} - - {/* ── GCSE Results ───────────────────────────────── */} - {hasResults && latestResults && ( -
-

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

-

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

- - - - {p8Suspended && ( -
- Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests - (COVID), so DfE has no starting point to measure their progress from. -
- )} - - {/* Hero stat cards — top GCSE metrics */} -
- {latestResults.attainment_8_score != null && ( -
-
- Attainment 8 score - -
-
- {latestResults.attainment_8_score.toFixed(1)} - {!suppressComparison && secondaryAvg.attainment_8_score != null && ( - - )} -
- {!suppressComparison && secondaryAvg.attainment_8_score != null && ( -
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
- )} -
- )} - {latestResults.progress_8_score != null && ( -
-
- Progress 8 score - -
-
- {formatProgress(latestResults.progress_8_score)} -
- {(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? ( -
- CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)} -
- ) : ( -
National baseline: 0.0
- )} -
- )} - {latestResults.english_maths_strong_pass_pct != null && ( -
-
- English & Maths Grade 5+ - -
-
- {formatPercentage(latestResults.english_maths_strong_pass_pct)} - {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( - - )} -
- {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( -
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
- )} -
- )} - {latestResults.english_maths_standard_pass_pct != null && ( -
-
- English & Maths Grade 4+ - -
-
- {formatPercentage(latestResults.english_maths_standard_pass_pct)} - {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( - - )} -
- {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( -
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
- )} -
- )} -
- - {/* Attainment 8 visual bar (0–80 scale). This viz is explicitly - "school vs national", so it's dropped for special schools where - that comparison isn't meaningful. */} - {!suppressComparison && latestResults.attainment_8_score != null && ( -
-
Attainment 8 — school vs national
-
-
- {secondaryAvg.attainment_8_score != null && ( -
-
- Nat avg {secondaryAvg.attainment_8_score.toFixed(1)} -
-
- )} -
-
- 020406080 -
-
- )} - - {/* Progress 8 number line with CI */} - {latestResults.progress_8_score != null && !p8Suspended && ( -
-
Progress 8 — relative to national baseline (0)
- {(() => { - const p8 = latestResults.progress_8_score!; - const lo = latestResults.progress_8_lower_ci ?? p8; - const hi = latestResults.progress_8_upper_ci ?? p8; - const range = 6; // −3 to +3 - const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`; - return ( -
- {/* CI band */} -
- {/* Zero line */} -
- {/* Score dot */} -
-
- ); - })()} -
- −3−2−10+1+2+3 -
-
- )} - - {/* Progress 8 component breakdown */} - {(latestResults.progress_8_english != null || latestResults.progress_8_maths != null || - latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && ( - <> -

Attainment 8 Components (Progress 8 contribution)

-
- {[ - { label: 'English', val: latestResults.progress_8_english }, - { label: 'Maths', val: latestResults.progress_8_maths }, - { label: 'EBacc subjects', val: latestResults.progress_8_ebacc }, - { label: 'Open (other GCSEs)', val: latestResults.progress_8_open }, - ].filter(r => r.val != null).map(({ label, val }) => ( -
- {label} - - {formatProgress(val!)} - -
- ))} -
- - )} - - {/* EBacc */} - {(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && ( - <> -

- English Baccalaureate (EBacc) - -

-
- {latestResults.ebacc_entry_pct != null && ( -
- Pupils entered for EBacc - {formatPercentage(latestResults.ebacc_entry_pct)} -
- )} - {latestResults.ebacc_standard_pass_pct != null && ( -
- EBacc Grade 4+ - {formatPercentage(latestResults.ebacc_standard_pass_pct)} -
- )} - {latestResults.ebacc_strong_pass_pct != null && ( -
- EBacc Grade 5+ - {formatPercentage(latestResults.ebacc_strong_pass_pct)} -
- )} - {latestResults.ebacc_avg_score != null && ( -
- EBacc average point score - {latestResults.ebacc_avg_score.toFixed(2)} -
- )} -
- - )} - -
- )} - - {/* ── Admissions ─────────────────────────────────── */} - {admissions && ( -
-

Admissions

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

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

- - {hasSixthForm && ( -
- This school has a sixth form (Post-16 provision). Post-16 destination data coming soon. -
- )} -
- )} - - {/* ── History table ──────────────────────────────── */} - {yearlyData.length > 1 && ( -
-

Historical Results

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

Results Over Time

-
- -
- - )} -
- View raw year-by-year data -
- - - - - - - - - - - - {yearlyData.map((result) => ( - - - - - - - - ))} - -
YearAttainment 8Progress 8Eng & Maths 4+EBacc entry %
{formatAcademicYear(result.year)}{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}
-
-
-
- )} - {/* ── Wellbeing ──────────────────────────────────── */} - {hasWellbeing && ( -
-

Wellbeing & Context

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

Special Educational Needs (SEN)

-
- {latestResults?.sen_support_pct != null && ( -
-
- SEN support - -
-
{formatPercentage(latestResults.sen_support_pct)}
-
Without an EHCP
-
- )} - {latestResults?.sen_ehcp_pct != null && ( -
-
- Pupils with EHCP - -
-
{formatPercentage(latestResults.sen_ehcp_pct)}
-
Education, Health and Care Plan
-
- )} - {(() => { - const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null; - if (total == null) return null; - const female = census?.female_pupils ?? null; - const male = census?.male_pupils ?? null; - const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; - const hasSplit = isMixed && female != null && male != null && female + male > 0; - const sum = hasSplit ? female! + male! : 0; - const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0; - const boysPct = hasSplit ? 100 - girlsPct : 0; - return ( -
-
Total pupils
-
{total.toLocaleString()}
- {hasSplit && ( - <> -
- - -
-
- {girlsPct}% girls - · - {boysPct}% boys -
- - )} - {schoolInfo.capacity != null && !hasSplit && ( -
Capacity: {schoolInfo.capacity}
- )} -
- ); - })()} -
- - )} - - {/* Deprivation */} - {hasDeprivation && deprivation && ( - <> -

- Local Area Context - -

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

{deprivationDesc(deprivation.idaci_decile!)}

- - )} -
- )} - - {/* ── Finances ───────────────────────────────────── */} - {hasFinance && finance && ( -
-

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

-

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

-
-
-
Total spend per pupil per year
-
£{Math.round(finance.per_pupil_spend!).toLocaleString()}
-
How much the school has to spend on each pupil annually
-
- {finance.teacher_cost_pct != null && ( -
-
Share of budget spent on teachers
-
{finance.teacher_cost_pct.toFixed(1)}%
-
- )} - {finance.staff_cost_pct != null && ( -
-
Share of budget spent on all staff
-
{finance.staff_cost_pct.toFixed(1)}%
-
- )} - {finance.premises_cost_pct != null && ( -
-
Share of budget spent on premises
-
{finance.premises_cost_pct.toFixed(1)}%
-
- )} -
-
- )} - -
- ); -} diff --git a/nextjs-app/components/school/AdmissionsSection.tsx b/nextjs-app/components/school/AdmissionsSection.tsx index 95f90dd..def1d3b 100644 --- a/nextjs-app/components/school/AdmissionsSection.tsx +++ b/nextjs-app/components/school/AdmissionsSection.tsx @@ -11,15 +11,14 @@ * toggle renders at all, so such pages ship zero admissions JavaScript. */ -import dynamic from 'next/dynamic'; import type { ReactNode } from 'react'; import type { SchoolAdmissions } from '@/lib/types'; import { formatAcademicYear, formatPercentage } from '@/lib/utils'; import { summariseAdmissions } from '@/lib/compareLogic'; import { Section, sectionStyles as styles } from './sectionShared'; import { AdmissionsViewToggle } from './AdmissionsViewToggle'; +import { AdmissionsTrendChart } from './charts'; -const AdmissionsTrendChart = dynamic(() => import('../AdmissionsTrendChart'), { ssr: false }); export function AdmissionsSection({ admissions, diff --git a/nextjs-app/components/school/HistorySection.tsx b/nextjs-app/components/school/HistorySection.tsx index 01df546..4f3c041 100644 --- a/nextjs-app/components/school/HistorySection.tsx +++ b/nextjs-app/components/school/HistorySection.tsx @@ -4,16 +4,10 @@ * were only 40% similar). Server component. */ -import dynamic from 'next/dynamic'; import type { School, SchoolResult, NationalAverages } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils'; import { Section, sectionStyles as styles } from './sectionShared'; - -const PerformanceChart = dynamic( - () => import('../PerformanceChart').then((m) => m.PerformanceChart), - { ssr: false }, -); -const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false }); +import { PerformanceChart, SatsChart } from './charts'; export function HistorySection({ yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg, diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx new file mode 100644 index 0000000..2cafb22 --- /dev/null +++ b/nextjs-app/components/school/PrimarySchoolSections.tsx @@ -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 && ( + + )} + + {flags.hasAnyResults && flags.latestResults && ( + + )} + + {admissions && ( + + )} + + {flags.hasInclusionData && ( + + )} + + {yearlyData.length > 0 && ( + + )} + + {flags.hasSchoolLife && ( + + )} + + {flags.hasDeprivation && deprivation && ( + + )} + + {flags.hasFinance && finance && } + + ); +} diff --git a/nextjs-app/components/school/ResultsSection.tsx b/nextjs-app/components/school/ResultsSection.tsx index d002a77..1937cd4 100644 --- a/nextjs-app/components/school/ResultsSection.tsx +++ b/nextjs-app/components/school/ResultsSection.tsx @@ -3,15 +3,14 @@ * Primary and all-through pages. Server component. */ -import dynamic from 'next/dynamic'; import type { School, SchoolResult } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils'; import { MetricTooltip } from '../MetricTooltip'; import { DeltaChip } from '../DeltaChip'; import { SpecialSchoolNote } from '../SpecialSchoolNote'; import { Section, sectionStyles as styles, progressClass } from './sectionShared'; +import { SatsChart } from './charts'; -const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false }); export function ResultsSection({ latestResults, schoolInfo, primaryAvg, secondaryAvg, diff --git a/nextjs-app/components/school/SchoolDetailShell.module.css b/nextjs-app/components/school/SchoolDetailShell.module.css new file mode 100644 index 0000000..229b1bf --- /dev/null +++ b/nextjs-app/components/school/SchoolDetailShell.module.css @@ -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; + } +} diff --git a/nextjs-app/components/school/SchoolDetailShell.tsx b/nextjs-app/components/school/SchoolDetailShell.tsx new file mode 100644 index 0000000..9ab0500 --- /dev/null +++ b/nextjs-app/components/school/SchoolDetailShell.tsx @@ -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(''); + // 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(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(null); + const [heroCtaVisible, setHeroCtaVisible] = useState(true); + // Hero map — the "View on map" link opens its fullscreen view. + const heroMapRef = useRef(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 = {}; + + 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 ( +
+ {/* 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). */} + + + {/* Header — the location map band blends down into the school title. */} +
+ {hasLocation && ( + + )} +
+
+

{schoolInfo.school_name}

+
+ {schoolInfo.local_authority && ( + {schoolInfo.local_authority} + )} + {schoolInfo.school_type && ( + {schoolInfo.school_type} + )} + {isAllThrough && ( + All-through (primary & secondary) + )} + {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( + {schoolInfo.gender}'s school + )} + {schoolInfo.age_range && ( + {formatAgeRange(schoolInfo.age_range)} + )} + {schoolInfo.nursery_provision && ( + Nursery + )} + {schoolInfo.has_sixth_form && ( + Sixth form + )} +
+ {isProposedToClose(schoolInfo) && ( +
+ ⚠ Proposed to close — this school is proposed for closure, + check with the local authority before applying. +
+ )} + {schoolInfo.address && ( +

+ {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} + {hasLocation && ( + <> + {' · '} + + + )} +

+ )} + +
+ {schoolInfo.headteacher_name && ( + + Headteacher: {schoolInfo.headteacher_name} + + )} + {schoolInfo.website && ( + + + School website ↗ + + + )} + {(() => { + const total = census?.total_pupils ?? latestResults?.total_pupils ?? null; + if (total == null) return null; + return ( + + Pupils: {total.toLocaleString()} + {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} + + ); + })()} + {schoolInfo.trust_name && ( + + Part of {schoolInfo.trust_name} + + )} + {schoolInfo.telephone && ( + + Phone:{' '} + + {schoolInfo.telephone} + + + )} + {schoolInfo.religious_denomination && ( + + Religious character:{' '} + {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) + ? 'None' + : schoolInfo.religious_denomination} + + )} + {schoolInfo.county && ( + + County: {schoolInfo.county} + + )} + {schoolInfo.parliamentary_constituency && ( + + Constituency: {schoolInfo.parliamentary_constituency} + + )} +
+
+
+ +
+
+
+ + {/* Sticky Section Navigation — docks under the global header */} + + + {children} +
+ ); +} diff --git a/nextjs-app/components/school/SecondaryHistorySection.tsx b/nextjs-app/components/school/SecondaryHistorySection.tsx index 2d73f2a..f6b9dac 100644 --- a/nextjs-app/components/school/SecondaryHistorySection.tsx +++ b/nextjs-app/components/school/SecondaryHistorySection.tsx @@ -3,15 +3,11 @@ * Server component. */ -import dynamic from 'next/dynamic'; import type { School, SchoolResult, NationalAverages } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils'; import { Section, sectionStyles as styles } from './sectionShared'; +import { PerformanceChart } from './charts'; -const PerformanceChart = dynamic( - () => import('../PerformanceChart').then((m) => m.PerformanceChart), - { ssr: false }, -); export function SecondaryHistorySection({ yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison, diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx new file mode 100644 index 0000000..4dd0925 --- /dev/null +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -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 ( +
+ {ofsted && ( + + )} + + {flags.hasResults && flags.latestResults && ( + + )} + + {admissions && ( + + )} + + {yearlyData.length > 1 && ( + + )} + + {flags.hasWellbeing && ( + + )} + + {flags.hasFinance && finance && ( + + )} +
+ ); +} diff --git a/nextjs-app/components/school/charts.tsx b/nextjs-app/components/school/charts.tsx new file mode 100644 index 0000000..83535a0 --- /dev/null +++ b/nextjs-app/components/school/charts.tsx @@ -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 }, +);