From 675601869bc495c5882b1075b1fb0e7183886aa3 Mon Sep 17 00:00:00 2001 From: Tudor Sitaru Date: Fri, 17 Apr 2026 22:36:33 +0100 Subject: [PATCH] feat(detail): show pupil gender split on school detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrades the existing "Pupils" stat to include a compact split bar and percentage hint for mixed schools (single-sex schools already carry a "Boys's/Girls's school" badge, so the split would be redundant). Wires fact_pupil_characteristics into the API: new SQLAlchemy model and a real census block in /api/schools/{urn} replacing the prior null stub. On the primary detail page the inline "Pupils: 241" text is replaced by a richer block (display number + bar + "52% girls · 48% boys"). On the secondary detail page the existing "Total pupils" hero stat card grows the bar and hint beneath the number. Both fall back to the previous text-only rendering when census gender data is missing. Co-Authored-By: Claude Opus 4.7 --- backend/data_loader.py | 18 +++- backend/models.py | 18 ++++ .../components/SchoolDetailView.module.css | 89 +++++++++++++++++++ nextjs-app/components/SchoolDetailView.tsx | 56 ++++++++++-- .../SecondarySchoolDetailView.module.css | 39 ++++++++ .../components/SecondarySchoolDetailView.tsx | 48 +++++++--- nextjs-app/lib/types.ts | 17 ++-- 7 files changed, 260 insertions(+), 25 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 41f698d..503b22b 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -15,7 +15,7 @@ from .database import SessionLocal, engine from .models import ( DimSchool, DimLocation, KS2Performance, FactOfstedInspection, FactParentView, FactAdmissions, - FactDeprivation, FactFinance, + FactDeprivation, FactFinance, FactPupilCharacteristics, ) from .schemas import SCHOOL_TYPE_MAP @@ -462,8 +462,20 @@ def get_supplementary_data(db: Session, urn: int) -> dict: else None ) - # Census (fact_pupil_characteristics — minimal until census columns are verified) - result["census"] = None + # Census (latest year of fact_pupil_characteristics) + pc = safe_query(FactPupilCharacteristics, "urn", "year") + result["census"] = ( + { + "year": pc.year, + "total_pupils": pc.total_pupils, + "female_pupils": pc.female_pupils, + "male_pupils": pc.male_pupils, + "fsm_pct": pc.fsm_pct, + "eal_pct": pc.eal_pct, + } + if pc + else None + ) # Admissions (latest year) a = safe_query(FactAdmissions, "urn", "year") diff --git a/backend/models.py b/backend/models.py index eef03ae..94b9d2e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -189,6 +189,24 @@ class FactAdmissions(Base): admissions_policy = Column(String(100)) +class FactPupilCharacteristics(Base): + """School pupil composition from EES census — one row per URN per year.""" + __tablename__ = "fact_pupil_characteristics" + __table_args__ = ( + Index("ix_pupil_chars_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + phase_type_grouping = Column(String(50)) + total_pupils = Column(Integer) + female_pupils = Column(Integer) + male_pupils = Column(Integer) + fsm_pct = Column(Float) + eal_pct = Column(Float) + + class FactDeprivation(Base): """IDACI deprivation index — one row per URN.""" __tablename__ = "fact_deprivation" diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index addf1d6..7a548d8 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -83,6 +83,95 @@ text-decoration: underline; } +/* Pupils block — the "Pupils: N" inline text is upgraded to a richer + display whenever we have census gender data and the school is Mixed. */ +.pupilsBlock { + display: inline-flex; + align-items: center; + gap: 0.7rem; + padding: 0.3rem 0.65rem 0.3rem 0.4rem; + background: rgba(243, 237, 228, 0.5); + border-radius: 10px; +} + +.pupilsFigure { + font-family: var(--font-playfair), 'Playfair Display', serif; + font-size: 1.6rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.01em; + color: var(--text-primary, #1a1612); +} + +.pupilsInfo { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 140px; +} + +.pupilsLegend { + font-size: 0.72rem; + color: var(--text-muted, #6d685f); + font-weight: 500; + line-height: 1; +} + +.pupilsCapacity { + color: var(--text-muted, #6d685f); +} + +.pupilsSplitRow { + display: flex; + align-items: baseline; + gap: 0.35rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; +} + +.pupilsSplitGirls { + color: #b45778; +} + +.pupilsSplitBoys { + color: var(--accent-teal, #2d7d7d); +} + +.pupilsSplitSep { + color: var(--border-color, #e5dfd5); + font-weight: 400; +} + +.pupilsBar { + display: flex; + height: 4px; + border-radius: 999px; + overflow: hidden; + background: var(--border-color, #e5dfd5); + width: 100%; + min-width: 140px; +} + +.pupilsBarGirls { + background: #b45778; +} + +.pupilsBarBoys { + background: var(--accent-teal, #2d7d7d); +} + +@media (max-width: 640px) { + .pupilsBlock { + display: flex; + width: 100%; + } + .pupilsInfo { + flex: 1; + min-width: 0; + } +} + .actions { display: flex; gap: 0.5rem; diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 497fefa..5a3baf0 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -235,12 +235,56 @@ export function SchoolDetailView({ )} - {latestResults?.total_pupils != null && ( - - Pupils: {latestResults.total_pupils.toLocaleString()} - {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} - - )} + {(() => { + const censusTotal = census?.total_pupils ?? null; + const female = census?.female_pupils ?? null; + const male = census?.male_pupils ?? null; + const total = censusTotal ?? latestResults?.total_pupils ?? null; + const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; + const hasSplit = isMixed && female != null && male != null && female + male > 0; + + if (hasSplit && total != null) { + const sum = female! + male!; + const girlsPct = Math.round((female! / sum) * 100); + const boysPct = 100 - girlsPct; + return ( +
+
{total.toLocaleString()}
+
+
+ pupils + {schoolInfo.capacity != null && ( + · capacity {schoolInfo.capacity.toLocaleString()} + )} +
+
+ {girlsPct}% girls + · + {boysPct}% boys +
+
+ + +
+
+
+ ); + } + + if (total != null) { + return ( + + Pupils: {total.toLocaleString()} + {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} + + ); + } + return null; + })()} {schoolInfo.trust_name && ( Part of {schoolInfo.trust_name} diff --git a/nextjs-app/components/SecondarySchoolDetailView.module.css b/nextjs-app/components/SecondarySchoolDetailView.module.css index 0757bae..00efe51 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.module.css +++ b/nextjs-app/components/SecondarySchoolDetailView.module.css @@ -884,6 +884,45 @@ margin-top: 0; } +/* Gender split — attached beneath the Total pupils stat value */ +.genderBar { + display: flex; + height: 4px; + border-radius: 999px; + overflow: hidden; + background: rgba(0, 0, 0, 0.08); + margin-top: 0.45rem; +} + +.genderBarGirls { + background: #b45778; +} + +.genderBarBoys { + background: var(--accent-teal, #2d7d7d); +} + +.genderSplitHint { + font-size: 0.7rem; + color: var(--text-muted, #6d685f); + margin-top: 0.35rem; + font-weight: 500; +} + +.genderSplitGirls { + color: #b45778; + font-weight: 600; +} + +.genderSplitBoys { + color: var(--accent-teal, #2d7d7d); + font-weight: 600; +} + +.genderSplitSep { + color: var(--border-color, #e5dfd5); +} + /* ── Attainment 8 visual bar ─────────────────────────── */ .att8Viz { margin: 1.25rem 0 0.5rem; diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index ec86bce..e4224d4 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -70,7 +70,7 @@ interface SecondarySchoolDetailViewProps { export function SecondarySchoolDetailView({ schoolInfo, yearlyData, - ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData, + ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData, }: SecondarySchoolDetailViewProps) { const router = useRouter(); const { addSchool, removeSchool, isSelected } = useComparison(); @@ -793,15 +793,43 @@ export function SecondarySchoolDetailView({
Education, Health and Care Plan
)} - {(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && ( -
-
Total pupils
-
{(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
- {schoolInfo.capacity != null && ( -
Capacity: {schoolInfo.capacity}
- )} -
- )} + {(() => { + const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null; + if (total == null) return null; + const female = census?.female_pupils ?? null; + const male = census?.male_pupils ?? null; + const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; + const hasSplit = isMixed && female != null && male != null && female + male > 0; + const sum = hasSplit ? female! + male! : 0; + const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0; + const boysPct = hasSplit ? 100 - girlsPct : 0; + return ( +
+
Total pupils
+
{total.toLocaleString()}
+ {hasSplit && ( + <> +
+ + +
+
+ {girlsPct}% girls + · + {boysPct}% boys +
+ + )} + {schoolInfo.capacity != null && !hasSplit && ( +
Capacity: {schoolInfo.capacity}
+ )} +
+ ); + })()} )} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index f15462a..d268d29 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -120,12 +120,17 @@ export interface OfstedParentView { export interface SchoolCensus { year: number; - class_size_avg: number | null; - ethnicity_white_pct: number | null; - ethnicity_asian_pct: number | null; - ethnicity_black_pct: number | null; - ethnicity_mixed_pct: number | null; - ethnicity_other_pct: number | null; + total_pupils: number | null; + female_pupils: number | null; + male_pupils: number | null; + fsm_pct: number | null; + eal_pct: number | null; + class_size_avg?: number | null; + ethnicity_white_pct?: number | null; + ethnicity_asian_pct?: number | null; + ethnicity_black_pct?: number | null; + ethnicity_mixed_pct?: number | null; + ethnicity_other_pct?: number | null; } export interface SchoolAdmissions {