From e8175561d5347ddce4fa88d5e298e64734902d33 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 28 Mar 2026 22:36:00 +0000 Subject: [PATCH] updates for secondary schools --- backend/app.py | 40 + backend/data_loader.py | 2 + backend/schemas.py | 4 + nextjs-app/app/page.tsx | 4 +- nextjs-app/app/rankings/page.tsx | 4 +- nextjs-app/app/school/[urn]/page.tsx | 54 +- nextjs-app/components/FilterBar.tsx | 49 +- nextjs-app/components/HomeView.module.css | 18 + nextjs-app/components/HomeView.tsx | 110 ++- nextjs-app/components/SchoolDetailView.tsx | 4 +- .../SecondarySchoolDetailView.module.css | 696 +++++++++++++++ .../components/SecondarySchoolDetailView.tsx | 801 ++++++++++++++++++ .../components/SecondarySchoolRow.module.css | 219 +++++ nextjs-app/components/SecondarySchoolRow.tsx | 168 ++++ nextjs-app/lib/api.ts | 20 + nextjs-app/lib/types.ts | 19 +- 16 files changed, 2170 insertions(+), 42 deletions(-) create mode 100644 nextjs-app/components/SecondarySchoolDetailView.module.css create mode 100644 nextjs-app/components/SecondarySchoolDetailView.tsx create mode 100644 nextjs-app/components/SecondarySchoolRow.module.css create mode 100644 nextjs-app/components/SecondarySchoolRow.tsx 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 && ( + + )} + + + + {filters.admissions_policies && filters.admissions_policies.length > 0 && ( + + )} + + )} + {hasActiveFilters && ( + )} +
)} )} 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 ─────────────────────────────────────── */} +
+
+
+

{schoolInfo.school_name}

+
+ {schoolInfo.school_type && ( + {schoolInfo.school_type} + )} + {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( + {schoolInfo.gender}'s school + )} + {schoolInfo.age_range && ( + {schoolInfo.age_range} + )} + {hasSixthForm && ( + Sixth form + )} + {admissionsTag && ( + + {admissionsTag} + + )} +
+ {schoolInfo.address && ( +

+ {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} +

+ )} +
+ {schoolInfo.headteacher_name && ( + + Headteacher: {schoolInfo.headteacher_name} + + )} + {schoolInfo.website && ( + + + School website ↗ + + + )} + {latestResults?.total_pupils != null && ( + + Pupils: {latestResults.total_pupils.toLocaleString()} + {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} + + )} + {schoolInfo.trust_name && ( + + Part of {schoolInfo.trust_name} + + )} +
+
+
+ +
+
+
+ + {/* ── Tab navigation (sticky) ─────────────────────── */} + + + {/* ── 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!)}% +
+ ))} +
+ +
+ )} +
+ )} + + {/* ── 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

+
+ + + + + + + + + + + + {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) : '-'}
+
+ + )} +
+ )} +
+ )} + + {/* ── 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) && ( + + )} +
+
+ ); +} 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 {