diff --git a/backend/app.py b/backend/app.py
index 1983754..70c3d54 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -218,6 +218,9 @@ async def get_schools(
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
page: int = Query(1, ge=1, le=1000, description="Page number"),
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
+ gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
+ admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
+ has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
):
"""
Get list of schools with pagination.
@@ -253,6 +256,13 @@ async def get_schools(
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
)
+ if "attainment_8_score" in df_prev.columns:
+ prev_rwm = prev_rwm.merge(
+ df_prev[["urn", "attainment_8_score"]].rename(
+ columns={"attainment_8_score": "prev_attainment_8_score"}
+ ),
+ on="urn", how="outer"
+ )
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
# Phase filter
@@ -270,6 +280,16 @@ async def get_schools(
schools_df_phase_mask = df_latest["phase"].str.lower().str.contains(phase_substr, na=False)
df_latest = df_latest[schools_df_phase_mask]
+ # Secondary-specific filters (after phase filter)
+ if gender:
+ df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
+ if admissions_policy:
+ df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
+ if has_sixth_form == "yes":
+ df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
+ elif has_sixth_form == "no":
+ df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
+
# Include key result metrics for display on cards
location_cols = ["latitude", "longitude"]
result_cols = [
@@ -278,6 +298,7 @@ async def get_schools(
"rwm_expected_pct",
"rwm_high_pct",
"prev_rwm_expected_pct",
+ "prev_attainment_8_score",
"reading_expected_pct",
"writing_expected_pct",
"maths_expected_pct",
@@ -510,14 +531,33 @@ async def get_filter_options(request: Request):
# Phases: return values from data, ordered sensibly
phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else []
+ secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
+ genders = sorted(secondary_df["gender"].dropna().unique().tolist()) if "gender" in secondary_df.columns else []
+ admissions_policies = sorted(secondary_df["admissions_policy"].dropna().unique().tolist()) if "admissions_policy" in secondary_df.columns else []
+
return {
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
"years": sorted(df["year"].dropna().unique().tolist()),
"phases": phases,
+ "genders": genders,
+ "admissions_policies": admissions_policies,
}
+@app.get("/api/la-averages")
+@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
+async def get_la_averages(request: Request):
+ """Get per-LA average Attainment 8 score for secondary schools in the latest year."""
+ df = load_school_data()
+ if df.empty:
+ return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
+ latest_year = int(df["year"].max())
+ sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
+ la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
+ return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
+
+
@app.get("/api/national-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
diff --git a/backend/data_loader.py b/backend/data_loader.py
index 95e1dd4..9029e4c 100644
--- a/backend/data_loader.py
+++ b/backend/data_loader.py
@@ -124,6 +124,7 @@ _MAIN_QUERY = text("""
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
+ s.admissions_policy,
s.capacity,
s.headteacher_name,
s.website,
@@ -211,6 +212,7 @@ _MAIN_QUERY = text("""
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
+ s.admissions_policy,
s.capacity,
s.headteacher_name,
s.website,
diff --git a/backend/schemas.py b/backend/schemas.py
index b3e0f0d..b660907 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -543,6 +543,10 @@ SCHOOL_COLUMNS = [
"postcode",
"religious_denomination",
"age_range",
+ "gender",
+ "admissions_policy",
+ "ofsted_grade",
+ "ofsted_date",
"latitude",
"longitude",
]
diff --git a/nextjs-app/app/page.tsx b/nextjs-app/app/page.tsx
index 56d3206..f73d401 100644
--- a/nextjs-app/app/page.tsx
+++ b/nextjs-app/app/page.tsx
@@ -68,7 +68,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return (
);
@@ -79,7 +79,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return (
);
diff --git a/nextjs-app/app/rankings/page.tsx b/nextjs-app/app/rankings/page.tsx
index 1544a64..90f6ba1 100644
--- a/nextjs-app/app/rankings/page.tsx
+++ b/nextjs-app/app/rankings/page.tsx
@@ -49,7 +49,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
return (
-
+ {isSecondary ? (
+
+ ) : (
+
+ )}
>
);
}
diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx
index 9dc25b5..d514681 100644
--- a/nextjs-app/components/FilterBar.tsx
+++ b/nextjs-app/components/FilterBar.tsx
@@ -28,6 +28,9 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || '';
const currentPhase = searchParams.get('phase') || '';
+ const currentGender = searchParams.get('gender') || '';
+ const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
+ const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -87,7 +90,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
});
};
- const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode;
+ const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm;
+ const isSecondaryMode = currentPhase === 'secondary' || (filters.genders && filters.genders.length > 0);
return (
@@ -169,6 +173,49 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
)}
+ {isSecondaryMode && (
+ <>
+ {filters.genders && filters.genders.length > 0 && (
+
handleFilterChange('gender', e.target.value)}
+ className={styles.filterSelect}
+ disabled={isPending}
+ >
+ Boys, Girls & Mixed
+ {filters.genders.map((g) => (
+ {g}
+ ))}
+
+ )}
+
+
handleFilterChange('has_sixth_form', e.target.value)}
+ className={styles.filterSelect}
+ disabled={isPending}
+ >
+ With or without sixth form
+ With sixth form (11–18)
+ Without sixth form (11–16)
+
+
+ {filters.admissions_policies && filters.admissions_policies.length > 0 && (
+
handleFilterChange('admissions_policy', e.target.value)}
+ className={styles.filterSelect}
+ disabled={isPending}
+ >
+ All admissions types
+ {filters.admissions_policies.map((p) => (
+ {p}
+ ))}
+
+ )}
+ >
+ )}
+
{hasActiveFilters && (
Clear Filters
diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css
index 7a25735..b8cf4cd 100644
--- a/nextjs-app/components/HomeView.module.css
+++ b/nextjs-app/components/HomeView.module.css
@@ -549,3 +549,21 @@
.chipRemove:hover {
color: var(--text-primary);
}
+
+.loadMoreSection {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1.5rem 0;
+}
+
+.loadMoreCount {
+ font-size: 0.875rem;
+ color: var(--text-muted, #8a847a);
+ margin: 0;
+}
+
+.loadMoreButton {
+ min-width: 160px;
+}
diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx
index baffd81..26dca35 100644
--- a/nextjs-app/components/HomeView.tsx
+++ b/nextjs-app/components/HomeView.tsx
@@ -5,14 +5,15 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { FilterBar } from './FilterBar';
import { SchoolRow } from './SchoolRow';
+import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap';
-import { Pagination } from './Pagination';
import { EmptyState } from './EmptyState';
import { useComparisonContext } from '@/context/ComparisonContext';
+import { fetchSchools, fetchLAaverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import styles from './HomeView.module.css';
@@ -28,19 +29,67 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
const [selectedMapSchool, setSelectedMapSchool] = useState(null);
const [sortOrder, setSortOrder] = useState('default');
+ const [allSchools, setAllSchools] = useState(initialSchools.schools);
+ const [currentPage, setCurrentPage] = useState(initialSchools.page);
+ const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const [laAverages, setLaAverages] = useState>({});
+ const prevSearchParamsRef = useRef(searchParams.toString());
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
+ const currentPhase = searchParams.get('phase') || '';
+ const hasSecondaryResults = allSchools.some(s => s.attainment_8_score != null);
+ const isSecondaryView = currentPhase.toLowerCase().includes('secondary') || hasSecondaryResults;
+
+ // Reset pagination state when search params change
+ useEffect(() => {
+ const newParamsStr = searchParams.toString();
+ if (newParamsStr !== prevSearchParamsRef.current) {
+ prevSearchParamsRef.current = newParamsStr;
+ setAllSchools(initialSchools.schools);
+ setCurrentPage(initialSchools.page);
+ setHasMore(initialSchools.total_pages > 1);
+ }
+ }, [searchParams, initialSchools]);
// Close bottom sheet if we change views or search
useEffect(() => {
setSelectedMapSchool(null);
}, [resultsView, searchParams]);
- const sortedSchools = [...initialSchools.schools].sort((a, b) => {
+ // Fetch LA averages when secondary schools are visible
+ useEffect(() => {
+ if (!isSecondaryView) return;
+ fetchLAaverages({ cache: 'force-cache' })
+ .then(data => setLaAverages(data.secondary.attainment_8_by_la))
+ .catch(() => {});
+ }, [isSecondaryView]);
+
+ const handleLoadMore = async () => {
+ if (isLoadingMore || !hasMore) return;
+ setIsLoadingMore(true);
+ try {
+ const params: Record = {};
+ searchParams.forEach((value, key) => { params[key] = value; });
+ params.page = currentPage + 1;
+ const response = await fetchSchools(params, { cache: 'no-store' });
+ setAllSchools(prev => [...prev, ...response.schools]);
+ setCurrentPage(response.page);
+ setHasMore(response.page < response.total_pages);
+ } catch {
+ // silently ignore
+ } finally {
+ setIsLoadingMore(false);
+ }
+ };
+
+ const sortedSchools = [...allSchools].sort((a, b) => {
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
+ if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
+ if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity);
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
return 0;
@@ -134,8 +183,10 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
setSortOrder(e.target.value)} className={styles.sortSelect}>
Sort: Relevance
- Highest R, W & M %
- Lowest R, W & M %
+ {!isSecondaryView && Highest R, W & M % }
+ {!isSecondaryView && Lowest R, W & M % }
+ {isSecondaryView && Highest Attainment 8 }
+ {isSecondaryView && Lowest Attainment 8 }
{isLocationSearch && Nearest first }
Name A–Z
@@ -206,23 +257,44 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<>
{sortedSchools.map((school) => (
- s.urn === school.urn)}
- />
+ isSecondaryView || school.attainment_8_score != null ? (
+ s.urn === school.urn)}
+ laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
+ />
+ ) : (
+ s.urn === school.urn)}
+ />
+ )
))}
- {initialSchools.total_pages > 1 && (
-
+ {(hasMore || allSchools.length < initialSchools.total) && (
+
+
+ Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
+
+ {hasMore && (
+
+ {isLoadingMore ? 'Loading...' : 'Load more schools'}
+
+ )}
+
)}
>
)}
diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx
index 0794dc6..6b74d24 100644
--- a/nextjs-app/components/SchoolDetailView.tsx
+++ b/nextjs-app/components/SchoolDetailView.tsx
@@ -670,10 +670,10 @@ export function SchoolDetailView({
{admissions.total_applications.toLocaleString()}
)}
- {admissions.first_preference_offers_pct != null && (
+ {admissions.first_preference_offer_pct != null && (
Families who got their first-choice
-
{admissions.first_preference_offers_pct}%
+
{admissions.first_preference_offer_pct}%
)}
diff --git a/nextjs-app/components/SecondarySchoolDetailView.module.css b/nextjs-app/components/SecondarySchoolDetailView.module.css
new file mode 100644
index 0000000..82ccf46
--- /dev/null
+++ b/nextjs-app/components/SecondarySchoolDetailView.module.css
@@ -0,0 +1,696 @@
+/* SecondarySchoolDetailView — borrows heavily from SchoolDetailView.module.css */
+
+.container {
+ width: 100%;
+}
+
+/* ── Header ──────────────────────────────────────────── */
+.header {
+ background: var(--bg-card, white);
+ border: 1px solid var(--border-color, #e5dfd5);
+ border-radius: 10px;
+ padding: 1.25rem 1.5rem;
+ margin-bottom: 0;
+ box-shadow: var(--shadow-soft);
+}
+
+.headerContent {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1.5rem;
+}
+
+.titleSection {
+ flex: 1;
+}
+
+.schoolName {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary, #1a1612);
+ margin-bottom: 0.5rem;
+ line-height: 1.2;
+ font-family: var(--font-playfair), 'Playfair Display', serif;
+}
+
+.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;
+}
+
+.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;
+}
+
+.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, #e07256);
+ color: white;
+}
+
+.btnAdd:hover {
+ background: var(--accent-coral-dark, #c45a3f);
+ transform: translateY(-1px);
+}
+
+.btnRemove {
+ background: var(--accent-teal, #2d7d7d);
+ color: white;
+}
+
+.btnRemove:hover {
+ opacity: 0.9;
+}
+
+/* ── Tab Navigation (sticky) ─────────────────────────── */
+.tabNav {
+ position: sticky;
+ top: 3.5rem;
+ 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, #e07256);
+ 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;
+}
+
+.tabBtn:hover {
+ background: var(--bg-secondary, #f3ede4);
+ color: var(--text-primary, #1a1612);
+}
+
+.tabBtnActive {
+ background: var(--accent-coral, #e07256);
+ color: white;
+ font-weight: 600;
+}
+
+.tabBtnActive:hover {
+ background: var(--accent-coral-dark, #c45a3f);
+ color: white;
+}
+
+/* ── Tab content area ────────────────────────────────── */
+.tabContent {
+ display: flex;
+ flex-direction: column;
+ gap: 0; /* cards have their own margin-bottom */
+}
+
+/* ── 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);
+}
+
+/* ── 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;
+}
+
+.responseBadge {
+ font-size: 0.75rem;
+ font-weight: 500;
+ font-family: var(--font-dm-sans), sans-serif;
+ color: var(--text-muted, #8a847a);
+ background: var(--bg-secondary, #f3ede4);
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ margin-left: auto;
+}
+
+/* ── 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;
+}
+
+.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, #e07256);
+ font-weight: 700;
+}
+
+/* ── Status colours ──────────────────────────────────── */
+.statusGood {
+ background: var(--accent-teal-bg);
+ color: var(--accent-teal, #2d7d7d);
+}
+
+.statusWarn {
+ background: var(--accent-gold-bg);
+ color: #b8920e;
+}
+
+/* ── 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 ──────────────────────────────────────────── */
+.chartContainer {
+ width: 100%;
+ height: 280px;
+ 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.3rem 0.75rem;
+ font-size: 1rem;
+ 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: #3c8c3c; }
+.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
+.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
+
+.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
+.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
+.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
+.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
+.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
+
+.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, #e07256);
+}
+
+.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;
+}
+
+/* ── Parent View ─────────────────────────────────────── */
+.parentRecommendLine {
+ font-size: 0.85rem;
+ color: var(--text-secondary, #5c564d);
+ margin: 0.5rem 0 0;
+}
+
+.parentRecommendLine strong {
+ color: var(--accent-teal, #2d7d7d);
+ font-weight: 700;
+}
+
+.parentViewGrid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.parentViewRow {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.875rem;
+}
+
+.parentViewLabel {
+ flex: 0 0 18rem;
+ color: var(--text-secondary, #5c564d);
+ font-size: 0.8125rem;
+}
+
+.parentViewBar {
+ flex: 1;
+ height: 0.5rem;
+ background: var(--bg-secondary, #f3ede4);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.parentViewFill {
+ height: 100%;
+ background: var(--accent-teal, #2d7d7d);
+ border-radius: 4px;
+ transition: width 0.4s ease;
+}
+
+.parentViewPct {
+ flex: 0 0 2.75rem;
+ text-align: right;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--text-primary, #1a1612);
+}
+
+/* Tab link (inline button styled as link) */
+.tabLink {
+ background: none;
+ border: none;
+ color: var(--accent-teal, #2d7d7d);
+ font-size: 0.8125rem;
+ cursor: pointer;
+ padding: 0;
+ margin-top: 0.75rem;
+ text-decoration: none;
+}
+
+.tabLink: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;
+}
+
+/* ── Responsive ──────────────────────────────────────── */
+@media (max-width: 768px) {
+ .headerContent {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .actions {
+ width: 100%;
+ }
+
+ .btnAdd,
+ .btnRemove {
+ flex: 1;
+ }
+
+ .schoolName {
+ font-size: 1.25rem;
+ }
+
+ .parentViewLabel {
+ flex-basis: 10rem;
+ }
+}
diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx
new file mode 100644
index 0000000..8deb5c6
--- /dev/null
+++ b/nextjs-app/components/SecondarySchoolDetailView.tsx
@@ -0,0 +1,801 @@
+/**
+ * SecondarySchoolDetailView Component
+ * Dedicated detail view for secondary schools with tabbed navigation:
+ * Overview | Academic | Admissions | Wellbeing | Parents | Finance
+ */
+
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useComparison } from '@/hooks/useComparison';
+import { PerformanceChart } from './PerformanceChart';
+import { MetricTooltip } from './MetricTooltip';
+import type {
+ School, SchoolResult, AbsenceData,
+ OfstedInspection, OfstedParentView, SchoolCensus,
+ SchoolAdmissions, SenDetail, Phonics,
+ SchoolDeprivation, SchoolFinance, NationalAverages,
+} from '@/lib/types';
+import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
+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' },
+];
+
+type Tab = 'overview' | 'academic' | 'admissions' | 'wellbeing' | 'parents' | 'finance';
+
+interface SecondarySchoolDetailViewProps {
+ schoolInfo: School;
+ yearlyData: SchoolResult[];
+ absenceData: AbsenceData | null;
+ ofsted: OfstedInspection | null;
+ parentView: OfstedParentView | null;
+ census: SchoolCensus | null;
+ admissions: SchoolAdmissions | null;
+ senDetail: SenDetail | null;
+ phonics: Phonics | null;
+ deprivation: SchoolDeprivation | null;
+ finance: SchoolFinance | null;
+}
+
+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).`;
+}
+
+export function SecondarySchoolDetailView({
+ schoolInfo, yearlyData,
+ ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
+}: SecondarySchoolDetailViewProps) {
+ const router = useRouter();
+ const { addSchool, removeSchool, isSelected } = useComparison();
+ const isInComparison = isSelected(schoolInfo.urn);
+
+ const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
+
+ const [activeTab, setActiveTab] = useState('overview');
+ const [nationalAvg, setNationalAvg] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/national-averages')
+ .then(r => r.ok ? r.json() : null)
+ .then(data => { if (data) setNationalAvg(data); })
+ .catch(() => {});
+ }, []);
+
+ const secondaryAvg = nationalAvg?.secondary ?? {};
+
+ const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
+ const hasFinance = finance != null && finance.per_pupil_spend != null;
+ const hasParents = parentView != null || ofsted != null;
+ const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
+ const p8Suspended = latestResults != null && latestResults.year >= 202425;
+
+ 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 tabs: { key: Tab; label: string }[] = [
+ { key: 'overview', label: 'Overview' },
+ { key: 'academic', label: 'Academic' },
+ { key: 'admissions', label: 'Admissions' },
+ { key: 'wellbeing', label: 'Wellbeing' },
+ ...(hasParents ? [{ key: 'parents' as Tab, label: 'Parents' }] : []),
+ ...(hasFinance ? [{ key: 'finance' as Tab, label: 'Finance' }] : []),
+ ];
+
+ const handleComparisonToggle = () => {
+ if (isInComparison) {
+ removeSchool(schoolInfo.urn);
+ } else {
+ addSchool(schoolInfo);
+ }
+ };
+
+ return (
+
+ {/* ── Header ─────────────────────────────────────── */}
+
+
+ {/* ── Tab navigation (sticky) ─────────────────────── */}
+
+
+
router.back()} className={styles.backBtn}>← Back
+
+ {tabs.map(({ key, label }) => (
+
setActiveTab(key)}
+ className={`${styles.tabBtn} ${activeTab === key ? styles.tabBtnActive : ''}`}
+ >
+ {label}
+
+ ))}
+
+
+
+ {/* ── Overview Tab ─────────────────────────────────── */}
+ {activeTab === 'overview' && (
+
+ {/* Ofsted summary card */}
+ {ofsted && (
+
+
+ {ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
+ {ofsted.inspection_date && (
+
+ {' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })}
+
+ )}
+
+ Full report ↗
+
+
+ {ofsted.framework === 'ReportCard' ? (
+
+ From November 2025, Ofsted replaced single overall grades with Report Cards.
+
+ ) : (
+
+
+ {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
+
+
+ )}
+ {parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
+
+ {Math.round(parentView.q_recommend_pct)}% of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
+
+ )}
+
+ )}
+
+ {/* Attainment 8 headline */}
+ {latestResults?.attainment_8_score != null && (
+
+
+ GCSE Performance at a Glance ({formatAcademicYear(latestResults.year)})
+
+
+
+
Attainment 8
+
{latestResults.attainment_8_score.toFixed(1)}
+ {secondaryAvg.attainment_8_score != null && (
+
National avg: {secondaryAvg.attainment_8_score.toFixed(1)}
+ )}
+
+ {latestResults.english_maths_standard_pass_pct != null && (
+
+
English & Maths Grade 4+
+
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
+ {secondaryAvg.english_maths_standard_pass_pct != null && (
+
National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
+ )}
+
+ )}
+ {admissions?.oversubscribed != null && (
+
+
Admissions
+
+ {admissions.oversubscribed ? 'Oversubscribed' : 'Places available'}
+
+
Last year
+
+ )}
+
+ {p8Suspended && (
+
+ Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
+
+ )}
+
+ )}
+
+ {/* Top Parent View scores */}
+ {parentView != null && parentView.total_responses != null && parentView.total_responses > 0 && (
+
+
What Parents Say
+
+ {[
+ { label: 'My child is happy here', pct: parentView.q_happy_pct },
+ { label: 'Would recommend this school', pct: parentView.q_recommend_pct },
+ ].filter(q => q.pct != null).map(({ label, pct }) => (
+
+
{label}
+
+
{Math.round(pct!)}%
+
+ ))}
+
+
setActiveTab('parents')} className={styles.tabLink}>
+ See all parent feedback →
+
+
+ )}
+
+ )}
+
+ {/* ── Academic Tab ─────────────────────────────────── */}
+ {activeTab === 'academic' && latestResults && (
+
+
+
+ GCSE Results ({formatAcademicYear(latestResults.year)})
+
+
+ GCSE results for Year 11 pupils. National averages shown for comparison.
+
+
+ {p8Suspended && (
+
+ Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
+
+ )}
+
+
+ {latestResults.attainment_8_score != null && (
+
+
+ Attainment 8
+
+
+
{latestResults.attainment_8_score.toFixed(1)}
+ {secondaryAvg.attainment_8_score != null && (
+
National avg: {secondaryAvg.attainment_8_score.toFixed(1)}
+ )}
+
+ )}
+ {latestResults.progress_8_score != null && (
+
+
+ Progress 8
+
+
+
+ {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) ?? '?'}
+
+ )}
+
+ )}
+ {latestResults.english_maths_standard_pass_pct != null && (
+
+
+ English & Maths Grade 4+
+
+
+
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
+ {secondaryAvg.english_maths_standard_pass_pct != null && (
+
National 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)}
+ {secondaryAvg.english_maths_strong_pass_pct != null && (
+
National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
+ )}
+
+ )}
+
+
+ {/* A8 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)}
+
+ )}
+
+ >
+ )}
+
+
+ {/* Performance over time */}
+ {yearlyData.length > 0 && (
+
+
Results Over Time
+
+ {yearlyData.length > 1 && (
+ <>
+
Detailed year-by-year figures
+
+
+
+
+ Year
+ Attainment 8
+ Progress 8
+ Eng & Maths 4+
+ EBacc entry %
+
+
+
+ {yearlyData.map((result) => (
+
+ {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) : '-'}
+
+ ))}
+
+
+
+ >
+ )}
+
+ )}
+
+ )}
+
+ {/* ── Admissions Tab ───────────────────────────────── */}
+ {activeTab === '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 ? (
+ <>
+
+ {admissions.published_admission_number != null && (
+
+
Year 7 places per year (PAN)
+
{admissions.published_admission_number}
+
+ )}
+ {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
+
{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.
+
+ >
+ ) : (
+
Admissions data for this school is not yet available.
+ )}
+
+ {hasSixthForm && (
+
+ This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
+
+ )}
+
+
+ )}
+
+ {/* ── Wellbeing Tab ────────────────────────────────── */}
+ {activeTab === 'wellbeing' && (
+
+ {/* SEN */}
+ {(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
+
+
Special Educational Needs (SEN)
+
+ {latestResults?.sen_support_pct != null && (
+
+
+ Pupils receiving SEN support
+
+
+
{formatPercentage(latestResults.sen_support_pct)}
+
SEN support without an EHCP
+
+ )}
+ {latestResults?.sen_ehcp_pct != null && (
+
+
+ Pupils with an EHCP
+
+
+
{formatPercentage(latestResults.sen_ehcp_pct)}
+
Education, Health and Care Plan
+
+ )}
+ {latestResults?.total_pupils != null && (
+
+
Total pupils
+
{latestResults.total_pupils.toLocaleString()}
+
+ )}
+
+
+ )}
+
+ {/* Deprivation */}
+ {hasDeprivation && deprivation && (
+
+
+ Local Area Context
+
+
+
+ {Array.from({ length: 10 }, (_, i) => (
+
+ ))}
+
+
+ Most deprived
+ Least deprived
+
+
{deprivationDesc(deprivation.idaci_decile!)}
+
+ )}
+
+ {latestResults?.sen_support_pct == null && latestResults?.sen_ehcp_pct == null && !hasDeprivation && (
+
+
Wellbeing data is not yet available for this school.
+
+ )}
+
+ )}
+
+ {/* ── Parents Tab ─────────────────────────────────── */}
+ {activeTab === 'parents' && (
+
+ {/* Full Ofsted detail */}
+ {ofsted && (
+
+
+ {ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
+ {ofsted.inspection_date && (
+
+ {' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
+
+ )}
+
+ Full report ↗
+
+
+ {ofsted.framework === 'ReportCard' ? (
+ <>
+
+ 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] : 'Not rated'}
+
+ {ofsted.previous_overall != null &&
+ ofsted.previous_overall !== ofsted.overall_effectiveness && (
+
+ Previously: {OFSTED_LABELS[ofsted.previous_overall]}
+
+ )}
+
+
+ From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
+
+
+ {[
+ { label: 'Quality of Teaching', value: ofsted.quality_of_education },
+ { label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
+ { label: 'Pupils\' Wider Development', value: ofsted.personal_development },
+ { label: 'School Leadership', value: ofsted.leadership_management },
+ ...(ofsted.early_years_provision != null
+ ? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
+ : []),
+ ].map(({ label, value }) => value != null && (
+
+
{label}
+
+ {OFSTED_LABELS[value]}
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+ {/* Parent View survey */}
+ {parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
+
+
+ What Parents Say
+
+ {parentView.total_responses.toLocaleString()} responses
+
+
+
+ From the Ofsted Parent View survey — parents share their experience of this school.
+
+
+ {[
+ { label: 'Would recommend this school', pct: parentView.q_recommend_pct },
+ { label: 'My child is happy here', pct: parentView.q_happy_pct },
+ { label: 'My child feels safe here', pct: parentView.q_safe_pct },
+ { label: 'Teaching is good', pct: parentView.q_teaching_pct },
+ { label: 'My child makes good progress', pct: parentView.q_progress_pct },
+ { label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
+ { label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
+ { label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
+ { label: 'Communicates well with parents', pct: parentView.q_communication_pct },
+ ].filter(q => q.pct != null).map(({ label, pct }) => (
+
+
{label}
+
+
{Math.round(pct!)}%
+
+ ))}
+
+
+ )}
+
+ {!ofsted && (!parentView || parentView.total_responses == null || parentView.total_responses === 0) && (
+
+
Parent and Ofsted data is not yet available for this school.
+
+ )}
+
+ )}
+
+ {/* ── Finance Tab ─────────────────────────────────── */}
+ {activeTab === 'finance' && 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/SecondarySchoolRow.module.css b/nextjs-app/components/SecondarySchoolRow.module.css
new file mode 100644
index 0000000..39009a6
--- /dev/null
+++ b/nextjs-app/components/SecondarySchoolRow.module.css
@@ -0,0 +1,219 @@
+.row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ background: var(--bg-card, white);
+ border: 1px solid var(--border-color, #e5dfd5);
+ border-left: 3px solid transparent;
+ border-radius: 8px;
+ padding: 0.75rem 1rem;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ animation: rowFadeIn 0.3s ease-out both;
+}
+
+.row:hover {
+ border-left-color: var(--accent-coral, #e07256);
+ box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
+}
+
+.rowInCompare {
+ border-left-color: var(--accent-teal, #2d7d7d);
+ background: var(--bg-secondary, #f3ede4);
+}
+
+@keyframes rowFadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Left content column ─────────────────────────────── */
+.rowContent {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+/* Line 1: name + type + ofsted */
+.line1 {
+ display: flex;
+ align-items: baseline;
+ gap: 0.625rem;
+ min-width: 0;
+}
+
+.schoolName {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--text-primary, #1a1612);
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 1;
+ min-width: 0;
+}
+
+.schoolName:hover {
+ color: var(--accent-coral, #e07256);
+}
+
+.schoolType {
+ font-size: 0.8rem;
+ color: var(--text-muted, #8a847a);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* Line 2: KS4 stats */
+.line2 {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0 1.25rem;
+}
+
+.stat {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.3rem;
+}
+
+.statValueLarge {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--text-primary, #1a1612);
+ font-family: var(--font-playfair), 'Playfair Display', serif;
+}
+
+.statValue {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: var(--text-primary, #1a1612);
+ font-family: var(--font-playfair), 'Playfair Display', serif;
+}
+
+.statLabel {
+ font-size: 0.75rem;
+ color: var(--text-muted, #8a847a);
+ white-space: nowrap;
+}
+
+.delta {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.deltaPositive { color: #3c8c3c; }
+.deltaNegative { color: var(--accent-coral, #e07256); }
+
+/* Line 3: location + tags */
+.line3 {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0 0;
+ font-size: 0.8rem;
+ color: var(--text-muted, #8a847a);
+}
+
+.line3 span:not(:last-child)::after {
+ content: '·';
+ margin: 0 0.4rem;
+ color: var(--border-color, #e5dfd5);
+}
+
+.distanceBadge {
+ display: inline-block;
+ padding: 0.0625rem 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: var(--accent-teal, #2d7d7d);
+ color: white;
+ border-radius: 3px;
+}
+
+.provisionTag {
+ display: inline-block;
+ padding: 0.0625rem 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ background: var(--bg-secondary, #f3ede4);
+ color: var(--text-secondary, #5c5650);
+ border-radius: 3px;
+ white-space: nowrap;
+}
+
+.selectiveTag {
+ background: rgba(180, 120, 0, 0.1);
+ color: #8a6200;
+}
+
+/* ── Ofsted badge ────────────────────────────────────── */
+.ofstedBadge {
+ display: inline-block;
+ padding: 0.0625rem 0.375rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ border-radius: 3px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ line-height: 1.4;
+}
+
+.ofstedDate {
+ font-weight: 400;
+}
+
+.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
+.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
+.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
+.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
+
+/* ── Right actions column ────────────────────────────── */
+.rowActions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.rowActions > * {
+ height: 2rem;
+ line-height: 1;
+ font-family: inherit;
+ box-sizing: border-box;
+}
+
+/* ── Mobile ──────────────────────────────────────────── */
+@media (max-width: 640px) {
+ .row {
+ flex-wrap: wrap;
+ padding: 0.75rem;
+ gap: 0.625rem;
+ }
+
+ .rowContent {
+ flex-basis: 100%;
+ }
+
+ .schoolName {
+ white-space: normal;
+ }
+
+ .line2 {
+ gap: 0 1rem;
+ }
+
+ .rowActions {
+ width: 100%;
+ gap: 0.375rem;
+ }
+
+ .rowActions > * {
+ flex: 1;
+ justify-content: center;
+ }
+}
diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx
new file mode 100644
index 0000000..9db9f46
--- /dev/null
+++ b/nextjs-app/components/SecondarySchoolRow.tsx
@@ -0,0 +1,168 @@
+/**
+ * SecondarySchoolRow Component
+ * Three-line row for secondary school search results
+ *
+ * Line 1: School name · School type · Ofsted badge
+ * Line 2: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
+ * Line 3: LA name · gender tag · sixth form tag · admissions tag · distance
+ */
+
+'use client';
+
+import type { School } from '@/lib/types';
+
+import styles from './SecondarySchoolRow.module.css';
+
+const OFSTED_LABELS: Record = {
+ 1: 'Outstanding',
+ 2: 'Good',
+ 3: 'Req. Improvement',
+ 4: 'Inadequate',
+};
+
+function detectAdmissionsTag(school: School): string | null {
+ const policy = school.admissions_policy?.toLowerCase() ?? '';
+ if (policy.includes('selective')) return 'Selective';
+ const denom = school.religious_denomination ?? '';
+ if (denom && denom !== 'Does not apply') return 'Faith priority';
+ return null;
+}
+
+function hasSixthForm(school: School): boolean {
+ return school.age_range?.includes('18') ?? false;
+}
+
+interface SecondarySchoolRowProps {
+ school: School;
+ isLocationSearch?: boolean;
+ isInCompare?: boolean;
+ onAddToCompare?: (school: School) => void;
+ onRemoveFromCompare?: (urn: number) => void;
+ laAvgAttainment8?: number | null;
+}
+
+export function SecondarySchoolRow({
+ school,
+ isLocationSearch,
+ isInCompare = false,
+ onAddToCompare,
+ onRemoveFromCompare,
+ laAvgAttainment8,
+}: SecondarySchoolRowProps) {
+ const handleCompareClick = () => {
+ if (isInCompare) {
+ onRemoveFromCompare?.(school.urn);
+ } else {
+ onAddToCompare?.(school);
+ }
+ };
+
+ const att8 = school.attainment_8_score ?? null;
+ const laDelta =
+ att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
+
+ const admissionsTag = detectAdmissionsTag(school);
+ const sixthForm = hasSixthForm(school);
+ const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
+
+ return (
+
+ {/* Left: three content lines */}
+
+
+ {/* Line 1: School name + type + Ofsted badge */}
+
+
+ {school.school_name}
+
+ {school.school_type && (
+
{school.school_type}
+ )}
+ {school.ofsted_grade && (
+
+ {OFSTED_LABELS[school.ofsted_grade]}
+ {school.ofsted_date && (
+
+ {' '}({new Date(school.ofsted_date).getFullYear()})
+
+ )}
+
+ )}
+
+
+ {/* Line 2: KS4 stats */}
+
+
+
+ {att8 != null ? att8.toFixed(1) : '—'}
+
+ Attainment 8
+
+
+ {laDelta != null && (
+ = 0 ? styles.deltaPositive : styles.deltaNegative}`}>
+ {laDelta >= 0 ? '+' : ''}{laDelta.toFixed(1)} vs LA avg
+
+ )}
+
+ {school.english_maths_standard_pass_pct != null && (
+
+
+ {school.english_maths_standard_pass_pct.toFixed(0)}%
+
+ Eng & Maths 4+
+
+ )}
+
+ {school.total_pupils != null && (
+
+
+ {school.total_pupils.toLocaleString()}
+
+ pupils
+
+ )}
+
+
+ {/* Line 3: Location + tags */}
+
+ {school.local_authority && (
+ {school.local_authority}
+ )}
+ {showGender && (
+ {school.gender}
+ )}
+ {sixthForm && (
+ Sixth form
+ )}
+ {admissionsTag && (
+
+ {admissionsTag}
+
+ )}
+ {isLocationSearch && school.distance != null && (
+
+ {school.distance.toFixed(1)} mi
+
+ )}
+
+
+
+
+ {/* Right: actions */}
+
+
+ View
+
+ {(onAddToCompare || onRemoveFromCompare) && (
+
+ {isInCompare ? '✓ Comparing' : '+ Compare'}
+
+ )}
+
+
+ );
+}
diff --git a/nextjs-app/lib/api.ts b/nextjs-app/lib/api.ts
index 7b43a84..476d460 100644
--- a/nextjs-app/lib/api.ts
+++ b/nextjs-app/lib/api.ts
@@ -14,6 +14,7 @@ import type {
SchoolSearchParams,
RankingsParams,
APIError,
+ LAaveragesResponse,
} from './types';
// ============================================================================
@@ -241,6 +242,25 @@ export async function fetchMetrics(
};
}
+/**
+ * Fetch per-LA average Attainment 8 score for secondary schools
+ */
+export async function fetchLAaverages(
+ options: RequestInit = {}
+): Promise {
+ const url = `${API_BASE_URL}/la-averages`;
+
+ const response = await fetch(url, {
+ ...options,
+ next: {
+ revalidate: 3600,
+ ...options.next,
+ },
+ });
+
+ return handleResponse(response);
+}
+
/**
* Fetch database statistics and info
*/
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index 7dbe5c7..10b97f1 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -54,6 +54,7 @@ export interface School {
// KS4 card metrics
attainment_8_score?: number | null;
english_maths_standard_pass_pct?: number | null;
+ prev_attainment_8_score?: number | null;
// GIAS enrichment fields
website?: string | null;
@@ -61,6 +62,7 @@ export interface School {
capacity?: number | null;
trust_name?: string | null;
gender?: string | null;
+ admissions_policy?: string | null;
// Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null;
@@ -127,9 +129,12 @@ export interface SchoolCensus {
export interface SchoolAdmissions {
year: number;
+ school_phase?: string | null;
published_admission_number: number | null;
total_applications: number | null;
- first_preference_offers_pct: number | null;
+ first_preference_applications?: number | null;
+ first_preference_offers?: number | null;
+ first_preference_offer_pct: number | null;
oversubscribed: boolean | null;
}
@@ -330,6 +335,8 @@ export interface Filters {
school_types: string[];
years: number[];
phases: string[];
+ genders: string[];
+ admissions_policies: string[];
}
export interface NationalAverages {
@@ -338,6 +345,13 @@ export interface NationalAverages {
secondary: Record;
}
+export interface LAaveragesResponse {
+ year: number;
+ secondary: {
+ attainment_8_by_la: Record;
+ };
+}
+
// Backend returns filters directly, not wrapped
export type FiltersResponse = Filters;
@@ -375,6 +389,9 @@ export interface SchoolSearchParams {
radius?: number;
page?: number;
page_size?: number;
+ gender?: string;
+ admissions_policy?: string;
+ has_sixth_form?: string;
}
export interface RankingsParams {