From 1d22877aec59fc6d32c8474f0fa89d39b2620192 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 29 Mar 2026 08:57:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(ux):=208=20UX=20improvements=20=E2=80=94?= =?UTF-8?q?=20simpler=20home,=20advanced=20filters,=20phase=20tabs,=204-li?= =?UTF-8?q?ne=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Simpler home page: only search box on landing, no filter dropdowns 2. Advanced filters: hidden behind toggle on results page, auto-open if active 3. Per-school phase rendering: each row renders based on its own data 4. Taller 4-line rows with context line (type, age range, denomination, gender) 5. Result-scoped filters: dropdown values reflect current search results 6. Fix blank filter values: exclude empty strings and "Not applicable" 7. Rankings: Primary/Secondary phase tabs with phase-specific metrics 8. Compare: Primary/Secondary tabs with school counts and phase metrics Co-Authored-By: Claude Opus 4.6 --- backend/app.py | 44 +- nextjs-app/app/rankings/page.tsx | 9 +- .../components/ComparisonView.module.css | 41 ++ nextjs-app/components/ComparisonView.tsx | 447 ++++++++++-------- nextjs-app/components/FilterBar.module.css | 48 +- nextjs-app/components/FilterBar.tsx | 219 +++++---- nextjs-app/components/HomeView.tsx | 3 +- nextjs-app/components/RankingsView.module.css | 41 ++ nextjs-app/components/RankingsView.tsx | 107 +++-- nextjs-app/components/SchoolRow.module.css | 41 +- nextjs-app/components/SchoolRow.tsx | 45 +- .../components/SecondarySchoolRow.module.css | 38 +- nextjs-app/components/SecondarySchoolRow.tsx | 50 +- nextjs-app/lib/types.ts | 10 + 14 files changed, 735 insertions(+), 408 deletions(-) diff --git a/backend/app.py b/backend/app.py index 70c3d54..ed421c5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -32,6 +32,17 @@ from .data_loader import get_data_info as get_db_info from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS from .utils import clean_for_json +# Values to exclude from filter dropdowns (empty strings, non-applicable labels) +EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"} + + +def clean_filter_values(series: pd.Series) -> list[str]: + """Return sorted unique values from a Series, excluding NaN and junk labels.""" + return sorted( + v for v in series.dropna().unique().tolist() + if v not in EXCLUDED_FILTER_VALUES + ) + # ============================================================================= # SECURITY MIDDLEWARE & HELPERS @@ -380,6 +391,15 @@ async def get_schools( schools_df["school_type"].str.lower() == school_type.lower() ] + # Compute result-scoped filter values (before pagination) + result_filters = { + "local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [], + "school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [], + "phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [], + "genders": clean_filter_values(schools_df["gender"]) if "gender" in schools_df.columns else [], + "admissions_policies": clean_filter_values(schools_df["admissions_policy"]) if "admissions_policy" in schools_df.columns else [], + } + # Pagination total = len(schools_df) start_idx = (page - 1) * page_size @@ -392,6 +412,7 @@ async def get_schools( "page": page, "page_size": page_size, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, + "result_filters": result_filters, "location_info": { "postcode": postcode, "radius": radius * 1.60934, # Convert miles to km for frontend display @@ -507,7 +528,11 @@ async def compare_schools( "urn": urn, "school_name": latest.get("school_name", ""), "local_authority": latest.get("local_authority", ""), + "school_type": latest.get("school_type", ""), "address": latest.get("address", ""), + "phase": latest.get("phase", ""), + "attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None, + "rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None, }, "yearly_data": clean_for_json(school_data), } @@ -529,15 +554,15 @@ 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 [] + phases = clean_filter_values(df["phase"]) 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 [] + genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else [] + admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) 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()), + "local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [], + "school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [], "years": sorted(df["year"].dropna().unique().tolist()), "phases": phases, "genders": genders, @@ -641,6 +666,9 @@ async def get_rankings( local_authority: Optional[str] = Query( None, description="Filter by local authority", max_length=100 ), + phase: Optional[str] = Query( + None, description="Filter by phase: primary or secondary", max_length=20 + ), ): """Get school rankings by a specific metric.""" # Sanitize local authority input @@ -670,6 +698,12 @@ async def get_rankings( if local_authority: df = df[df["local_authority"].str.lower() == local_authority.lower()] + # Filter by phase + if phase == "primary" and "rwm_expected_pct" in df.columns: + df = df[df["rwm_expected_pct"].notna()] + elif phase == "secondary" and "attainment_8_score" in df.columns: + df = df[df["attainment_8_score"].notna()] + # Sort and rank (exclude rows with no data for this metric) df = df.dropna(subset=[metric]) total = len(df) diff --git a/nextjs-app/app/rankings/page.tsx b/nextjs-app/app/rankings/page.tsx index 90f6ba1..7c1ab4c 100644 --- a/nextjs-app/app/rankings/page.tsx +++ b/nextjs-app/app/rankings/page.tsx @@ -12,6 +12,7 @@ interface RankingsPageProps { metric?: string; local_authority?: string; year?: string; + phase?: string; }>; } @@ -25,9 +26,10 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic'; export default async function RankingsPage({ searchParams }: RankingsPageProps) { - const { metric: metricParam, local_authority, year: yearParam } = await searchParams; + const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams; - const metric = metricParam || 'rwm_expected_pct'; + const phase = phaseParam || 'primary'; + const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); const year = yearParam ? parseInt(yearParam) : undefined; // Fetch rankings data with error handling @@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps) local_authority, year, limit: 100, + phase, }), fetchFilters(), fetchMetrics(), @@ -54,6 +57,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps) selectedMetric={metric} selectedArea={local_authority} selectedYear={year} + selectedPhase={phase} /> ); } catch (error) { @@ -68,6 +72,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps) selectedMetric={metric} selectedArea={local_authority} selectedYear={year} + selectedPhase={phase} /> ); } diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index 7e32309..0ba918a 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -31,6 +31,47 @@ } +/* Phase Tabs */ +.phaseTabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border: 1px solid var(--border-color, #e5dfd5); + border-radius: 8px; + overflow: hidden; + width: fit-content; +} + +.phaseTab { + padding: 0.625rem 1.5rem; + font-size: 0.9375rem; + font-weight: 500; + background: var(--bg-card, white); + color: var(--text-secondary, #5c564d); + border: none; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.phaseTab:not(:last-child) { + border-right: 1px solid var(--border-color, #e5dfd5); +} + +.phaseTab:hover { + background: var(--bg-secondary, #f3ede4); +} + +.phaseTabActive { + background: var(--accent-coral, #e07256); + color: white; + font-weight: 600; +} + +.phaseTabActive:hover { + background: var(--accent-coral, #e07256); +} + /* Metric Selector */ .metricSelector { background: var(--bg-card, white); diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index a5dc667..499d85f 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -1,6 +1,6 @@ /** * ComparisonView Component - * Client-side comparison interface with charts and tables + * Client-side comparison interface with phase tabs, charts, and tables */ 'use client'; @@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart'; import { SchoolSearchModal } from './SchoolSearchModal'; import { EmptyState } from './EmptyState'; import { LoadingSkeleton } from './LoadingSkeleton'; -import type { ComparisonData, MetricDefinition } from '@/lib/types'; +import type { ComparisonData, MetricDefinition, School } from '@/lib/types'; import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils'; import { fetchComparison } from '@/lib/api'; import styles from './ComparisonView.module.css'; +const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends']; +const SECONDARY_CATEGORIES = ['gcse']; + +const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [ + { label: 'Expected Standard', category: 'expected' }, + { label: 'Higher Standard', category: 'higher' }, + { label: 'Progress Scores', category: 'progress' }, + { label: 'Average Scores', category: 'average' }, + { label: 'Gender Performance', category: 'gender' }, + { label: 'Equity (Disadvantaged)', category: 'equity' }, + { label: 'School Context', category: 'context' }, + { label: 'Absence', category: 'absence' }, + { label: '3-Year Trends', category: 'trends' }, +]; + +const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [ + { label: 'GCSE Performance', category: 'gcse' }, +]; + interface ComparisonViewProps { initialData: Record | null; initialUrns: number[]; @@ -39,6 +58,7 @@ export function ComparisonView({ const [isModalOpen, setIsModalOpen] = useState(false); const [comparisonData, setComparisonData] = useState(initialData); const [shareConfirm, setShareConfirm] = useState(false); + const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); // Seed context from initialData when component mounts and localStorage is empty useEffect(() => { @@ -84,6 +104,37 @@ export function ComparisonView({ } }, [selectedSchools, selectedMetric, pathname, searchParams, router]); + // Classify schools by phase using comparison data + const classifySchool = (school: School): 'primary' | 'secondary' => { + const info = comparisonData?.[school.urn]?.school_info; + if (info?.attainment_8_score != null) return 'secondary'; + if (info?.rwm_expected_pct != null) return 'primary'; + // Fallback: check yearly data + const yearlyData = comparisonData?.[school.urn]?.yearly_data; + if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary'; + return 'primary'; + }; + + const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary'); + const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary'); + + // Auto-select tab with more schools + useEffect(() => { + if (comparisonData && selectedSchools.length > 0) { + if (secondarySchools.length > primarySchools.length) { + setComparePhase('secondary'); + } else { + setComparePhase('primary'); + } + } + }, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePhaseChange = (phase: 'primary' | 'secondary') => { + setComparePhase(phase); + const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; + setSelectedMetric(defaultMetric); + }; + const handleMetricChange = (metric: string) => { setSelectedMetric(metric); }; @@ -100,6 +151,12 @@ export function ComparisonView({ } catch { /* fallback: do nothing */ } }; + const isPrimary = comparePhase === 'primary'; + const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; + const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; + const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category)); + const activeSchools = isPrimary ? primarySchools : secondarySchools; + // Get metric definition const currentMetricDef = metrics.find((m) => m.key === selectedMetric); const metricLabel = currentMetricDef?.label || selectedMetric; @@ -129,10 +186,20 @@ export function ComparisonView({ ); } + // Build filtered comparison data for active phase + const activeComparisonData: Record = {}; + if (comparisonData) { + activeSchools.forEach(s => { + if (comparisonData[s.urn]) { + activeComparisonData[s.urn] = comparisonData[s.urn]; + } + }); + } + // Get years for table const years = - comparisonData && Object.keys(comparisonData).length > 0 - ? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year) + Object.keys(activeComparisonData).length > 0 + ? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year) : []; return ( @@ -158,208 +225,206 @@ export function ComparisonView({ - {/* Metric Selector */} -
- - - {currentMetricDef?.description && ( -

{currentMetricDef.description}

- )} -
+ Primary ({primarySchools.length}) + + + - {/* Progress score explanation */} - {selectedMetric.includes('progress') && ( -

- Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average. -

- )} - - {/* School Cards */} -
-
- {selectedSchools.map((school, index) => ( -
setIsModalOpen(true), + }} + /> + ) : ( + <> + {/* Metric Selector */} +
+ + + {currentMetricDef?.description && ( +

{currentMetricDef.description}

+ )} +
- {/* Latest metric value */} - {comparisonData && comparisonData[school.urn] && ( -
-
{metricLabel}
-
- - {(() => { - const yearlyData = comparisonData[school.urn].yearly_data; - if (yearlyData.length === 0) return '-'; + {/* Progress score explanation */} + {selectedMetric.includes('progress') && ( +

+ Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average. +

+ )} - const latestData = yearlyData[yearlyData.length - 1]; - const value = latestData[selectedMetric as keyof typeof latestData]; - - if (value === null || value === undefined) return '-'; - - // Format based on metric type - if (selectedMetric.includes('progress')) { - return formatProgress(value as number); - } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { - return formatPercentage(value as number); - } else { - return typeof value === 'number' ? value.toFixed(1) : String(value); - } - })()} + {/* School Cards */} +
+
+ {activeSchools.map((school, index) => ( +
+ +

+ {school.school_name} +

+
+ {school.local_authority && ( + {school.local_authority} + )} + {school.school_type && ( + {school.school_type} + )}
+ + {/* Latest metric value */} + {activeComparisonData[school.urn] && ( +
+
{metricLabel}
+
+ + {(() => { + const yearlyData = activeComparisonData[school.urn].yearly_data; + if (yearlyData.length === 0) return '-'; + + const latestData = yearlyData[yearlyData.length - 1]; + const value = latestData[selectedMetric as keyof typeof latestData]; + + if (value === null || value === undefined) return '-'; + + if (selectedMetric.includes('progress')) { + return formatProgress(value as number); + } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { + return formatPercentage(value as number); + } else { + return typeof value === 'number' ? value.toFixed(1) : String(value); + } + })()} +
+
+ )}
- )} + ))}
- ))} -
-
+ - {/* Comparison Chart */} - {comparisonData && Object.keys(comparisonData).length > 0 ? ( -
-

Performance Over Time

-
- -
-
- ) : selectedSchools.length > 0 ? ( -
- -
- ) : null} + {/* Comparison Chart */} + {Object.keys(activeComparisonData).length > 0 ? ( +
+

Performance Over Time

+
+ +
+
+ ) : activeSchools.length > 0 ? ( +
+ +
+ ) : null} - {/* Comparison Table */} - {comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && ( -
-

Detailed Comparison

-
- - - - - {selectedSchools.map((school) => ( - - ))} - - - - {years.map((year) => ( - - - {selectedSchools.map((school) => { - const schoolData = comparisonData[school.urn]; - if (!schoolData) return ; + {/* Comparison Table */} + {Object.keys(activeComparisonData).length > 0 && years.length > 0 && ( +
+

Detailed Comparison

+
+
Year{school.school_name}
{year}-
+ + + + {activeSchools.map((school) => ( + + ))} + + + + {years.map((year) => ( + + + {activeSchools.map((school) => { + const schoolData = activeComparisonData[school.urn]; + if (!schoolData) return ; - const yearData = schoolData.yearly_data.find((d) => d.year === year); - if (!yearData) return ; + const yearData = schoolData.yearly_data.find((d) => d.year === year); + if (!yearData) return ; - const value = yearData[selectedMetric as keyof typeof yearData]; + const value = yearData[selectedMetric as keyof typeof yearData]; - if (value === null || value === undefined) { - return ; - } + if (value === null || value === undefined) { + return ; + } - // Format based on metric type - let displayValue: string; - if (selectedMetric.includes('progress')) { - displayValue = formatProgress(value as number); - } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { - displayValue = formatPercentage(value as number); - } else { - displayValue = typeof value === 'number' ? value.toFixed(1) : String(value); - } + let displayValue: string; + if (selectedMetric.includes('progress')) { + displayValue = formatProgress(value as number); + } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { + displayValue = formatPercentage(value as number); + } else { + displayValue = typeof value === 'number' ? value.toFixed(1) : String(value); + } - return ; - })} - - ))} - -
Year{school.school_name}
{year}-----{displayValue}
-
-
+ return {displayValue}; + })} + + ))} + + + + + )} + )} {/* School Search Modal */} diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index fb1a7df..260a860 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -32,8 +32,12 @@ padding: 1.25rem 2.5rem; } +.heroMode .searchSection { + margin-bottom: 0; +} + .searchSection { - margin-bottom: 1rem; + margin-bottom: 0; } .omniBoxContainer { @@ -154,3 +158,45 @@ font-size: 0.875rem; cursor: pointer; } + +/* ── Advanced filters toggle ─────────────────────────── */ +.advancedSection { + margin-top: 0.75rem; + border-top: 1px solid var(--border-color, #e5dfd5); + padding-top: 0.5rem; +} + +.advancedToggle { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: none; + border: none; + padding: 0.375rem 0; + font-size: 0.8125rem; + color: var(--text-muted, #8a847a); + cursor: pointer; + font-family: inherit; + transition: color 0.15s ease; +} + +.advancedToggle:hover { + color: var(--text-secondary, #5a554d); +} + +.chevronDown, +.chevronUp { + display: inline-block; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; +} + +.chevronDown { + border-top: 5px solid currentColor; +} + +.chevronUp { + border-bottom: 5px solid currentColor; +} diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index d514681..4737837 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -3,15 +3,16 @@ import { useState, useCallback, useTransition, useRef, useEffect } from 'react'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { isValidPostcode } from '@/lib/utils'; -import type { Filters } from '@/lib/types'; +import type { Filters, ResultFilters } from '@/lib/types'; import styles from './FilterBar.module.css'; interface FilterBarProps { filters: Filters; isHero?: boolean; + resultFilters?: ResultFilters; } -export function FilterBar({ filters, isHero }: FilterBarProps) { +export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -32,9 +33,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { const currentAdmissionsPolicy = searchParams.get('admissions_policy') || ''; const currentHasSixthForm = searchParams.get('has_sixth_form') || ''; + // Count active dropdown filters (not search/postcode) + const activeDropdownFilters = [currentLA, currentType, currentPhase, currentGender, currentAdmissionsPolicy, currentHasSixthForm].filter(Boolean); + const hasActiveDropdownFilters = activeDropdownFilters.length > 0; + const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters); + + // Auto-open if filters become active (e.g. URL change) + useEffect(() => { + if (hasActiveDropdownFilters) setFiltersOpen(true); + }, [hasActiveDropdownFilters]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Focus search on '/' or Ctrl+K, but not when typing in an input if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA' && @@ -91,7 +101,15 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { }; const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm; - const isSecondaryMode = currentPhase === 'secondary' || (filters.genders && filters.genders.length > 0); + + // Use result-scoped filter values when available, fall back to global + const laOptions = resultFilters?.local_authorities ?? filters.local_authorities; + const typeOptions = resultFilters?.school_types ?? filters.school_types; + const phaseOptions = resultFilters?.phases ?? filters.phases ?? []; + const genderOptions = resultFilters?.genders ?? filters.genders ?? []; + const admissionsPolicyOptions = resultFilters?.admissions_policies ?? filters.admissions_policies ?? []; + + const isSecondaryMode = currentPhase === 'secondary' || genderOptions.length > 0; return (
@@ -128,100 +146,109 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { )} -
- - - - - {filters.phases && filters.phases.length > 0 && ( - - )} - - {isSecondaryMode && ( - <> - {filters.genders && filters.genders.length > 0 && ( - - )} - - - - {filters.admissions_policies && filters.admissions_policies.length > 0 && ( - - )} - - )} - - {hasActiveFilters && ( - - )} -
+ + {filtersOpen && ( +
+ + + + + {phaseOptions.length > 0 && ( + + )} + + {isSecondaryMode && ( + <> + {genderOptions.length > 0 && ( + + )} + + + + {admissionsPolicyOptions.length > 0 && ( + + )} + + )} + + {hasActiveFilters && ( + + )} +
+ )} +
+ )} ); } diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index 26dca35..3efeeb3 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -108,6 +108,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp {/* Discovery section shown on landing page before any search */} @@ -257,7 +258,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp <>
{sortedSchools.map((school) => ( - isSecondaryView || school.attainment_8_score != null ? ( + school.attainment_8_score != null ? ( ) => { const params = new URLSearchParams(searchParams); @@ -48,6 +73,11 @@ export function RankingsView({ router.push(`${pathname}?${params.toString()}`); }; + const handlePhaseChange = (phase: string) => { + const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; + updateFilters({ phase, metric: defaultMetric }); + }; + const handleMetricChange = (metric: string) => { updateFilters({ metric }); }; @@ -63,7 +93,6 @@ export function RankingsView({ const handleAddToCompare = (ranking: RankingEntry) => { addSchool({ ...ranking, - // Ensure required School fields are present address: null, postcode: null, latitude: null, @@ -77,6 +106,9 @@ export function RankingsView({ const isProgressScore = selectedMetric.includes('progress'); const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate'); + // Filter metrics to only show relevant categories + const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category)); + return (
{/* Header */} @@ -84,10 +116,26 @@ export function RankingsView({

School Rankings

Top-performing schools by {metricLabel.toLowerCase()} - {!selectedArea && — showing top {rankings.length}} + {!selectedArea && rankings.length > 0 && — showing top {rankings.length}}

+ {/* Phase Tabs */} +
+ + +
+ {currentMetricDef?.description && (

{currentMetricDef.description}

)} @@ -107,46 +155,17 @@ export function RankingsView({ onChange={(e) => handleMetricChange(e.target.value)} className={styles.filterSelect} > - - {metrics.filter(m => m.category === 'expected').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'higher').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'progress').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'average').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'gender').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'disadvantaged').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === 'context').map((metric) => ( - - ))} - - - {metrics.filter(m => m.category === '3yr').map((metric) => ( - - ))} - + {optgroups.map(({ label, category }) => { + const groupMetrics = filteredMetrics.filter(m => m.category === category); + if (groupMetrics.length === 0) return null; + return ( + + {groupMetrics.map((metric) => ( + + ))} + + ); + })}
@@ -199,7 +218,7 @@ export function RankingsView({ message="Try selecting a different metric, area, or year." action={{ label: 'Clear filters', - onClick: () => router.push(pathname), + onClick: () => router.push(`${pathname}?phase=${selectedPhase}`), }} /> ) : ( diff --git a/nextjs-app/components/SchoolRow.module.css b/nextjs-app/components/SchoolRow.module.css index d210f12..0b0e7ba 100644 --- a/nextjs-app/components/SchoolRow.module.css +++ b/nextjs-app/components/SchoolRow.module.css @@ -6,7 +6,7 @@ border: 1px solid var(--border-color, #e5dfd5); border-left: 3px solid transparent; border-radius: 8px; - padding: 0.75rem 1rem; + padding: 1rem 1.25rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; animation: rowFadeIn 0.3s ease-out both; } @@ -32,10 +32,10 @@ min-width: 0; display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.35rem; } -/* Line 1: name + type */ +/* Line 1: name + ofsted */ .line1 { display: flex; align-items: baseline; @@ -59,15 +59,24 @@ color: var(--accent-coral, #e07256); } -.schoolType { +/* Line 2: context tags */ +.line2 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; font-size: 0.8rem; color: var(--text-muted, #8a847a); - white-space: nowrap; - flex-shrink: 0; } -/* Line 2: stats */ -.line2 { +.line2 span:not(:last-child)::after { + content: '·'; + margin: 0 0.4rem; + color: var(--border-color, #e5dfd5); +} + +/* Line 3: stats */ +.line3 { display: flex; align-items: center; flex-wrap: wrap; @@ -107,17 +116,17 @@ .trendDown { color: var(--accent-coral, #e07256); } .trendStable { color: var(--text-muted, #8a847a); } -/* Line 3: location */ -.line3 { +/* Line 4: location */ +.line4 { display: flex; align-items: center; flex-wrap: wrap; - gap: 0 0; + gap: 0; font-size: 0.8rem; color: var(--text-muted, #8a847a); } -.line3 span:not(:last-child)::after { +.line4 span:not(:last-child)::after { content: '·'; margin: 0 0.4rem; color: var(--border-color, #e5dfd5); @@ -162,6 +171,10 @@ 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; } @@ -171,7 +184,7 @@ @media (max-width: 640px) { .row { flex-wrap: wrap; - padding: 0.75rem; + padding: 0.875rem; gap: 0.625rem; } @@ -183,7 +196,7 @@ white-space: normal; } - .line2 { + .line3 { gap: 0 1rem; } diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx index 863286d..fb932ab 100644 --- a/nextjs-app/components/SchoolRow.tsx +++ b/nextjs-app/components/SchoolRow.tsx @@ -1,10 +1,11 @@ /** * SchoolRow Component - * Three-line row for school search results + * Four-line row for primary school search results * - * Line 1: School name · School type - * Line 2: R,W&M % · Progress score · Pupil count - * Line 3: Local authority · Distance + * Line 1: School name · Ofsted badge + * Line 2: School type · Age range · Denomination · Gender + * Line 3: R,W&M % · Progress score · Pupil count + * Line 4: Local authority · Distance */ import type { School } from '@/lib/types'; @@ -48,28 +49,43 @@ export function SchoolRow({ } }; + const showGender = school.gender && school.gender.toLowerCase() !== 'mixed'; + const showDenomination = + school.religious_denomination && + school.religious_denomination !== 'Does not apply'; + return (
- {/* Left: three content lines */} + {/* Left: four content lines */}
- {/* Line 1: School name + type + Ofsted badge */} + {/* Line 1: School name + 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: Key stats */} + {/* Line 2: Context tags */}
+ {school.school_type && {school.school_type}} + {school.age_range && {school.age_range}} + {showDenomination && {school.religious_denomination}} + {showGender && {school.gender}} +
+ + {/* Line 3: Key stats */} +
{school.rwm_expected_pct != null ? ( @@ -123,8 +139,8 @@ export function SchoolRow({ )}
- {/* Line 3: Location + distance */} -
+ {/* Line 4: Location + distance */} +
{school.local_authority && ( {school.local_authority} )} @@ -133,11 +149,6 @@ export function SchoolRow({ {school.distance.toFixed(1)} mi )} - {!isLocationSearch && - school.religious_denomination && - school.religious_denomination !== 'Does not apply' && ( - {school.religious_denomination} - )}
diff --git a/nextjs-app/components/SecondarySchoolRow.module.css b/nextjs-app/components/SecondarySchoolRow.module.css index 39009a6..f31ff4a 100644 --- a/nextjs-app/components/SecondarySchoolRow.module.css +++ b/nextjs-app/components/SecondarySchoolRow.module.css @@ -6,7 +6,7 @@ border: 1px solid var(--border-color, #e5dfd5); border-left: 3px solid transparent; border-radius: 8px; - padding: 0.75rem 1rem; + padding: 1rem 1.25rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; animation: rowFadeIn 0.3s ease-out both; } @@ -32,10 +32,10 @@ min-width: 0; display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.35rem; } -/* Line 1: name + type + ofsted */ +/* Line 1: name + ofsted */ .line1 { display: flex; align-items: baseline; @@ -59,15 +59,24 @@ color: var(--accent-coral, #e07256); } -.schoolType { +/* Line 2: context tags */ +.line2 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem 0; font-size: 0.8rem; color: var(--text-muted, #8a847a); - white-space: nowrap; - flex-shrink: 0; } -/* Line 2: KS4 stats */ -.line2 { +.line2 > span:not(.provisionTag):not(:last-child)::after { + content: '·'; + margin: 0 0.4rem; + color: var(--border-color, #e5dfd5); +} + +/* Line 3: KS4 stats */ +.line3 { display: flex; align-items: center; flex-wrap: wrap; @@ -109,17 +118,17 @@ .deltaPositive { color: #3c8c3c; } .deltaNegative { color: var(--accent-coral, #e07256); } -/* Line 3: location + tags */ -.line3 { +/* Line 4: location + distance */ +.line4 { display: flex; align-items: center; flex-wrap: wrap; - gap: 0 0; + gap: 0; font-size: 0.8rem; color: var(--text-muted, #8a847a); } -.line3 span:not(:last-child)::after { +.line4 span:not(:last-child)::after { content: '·'; margin: 0 0.4rem; color: var(--border-color, #e5dfd5); @@ -144,6 +153,7 @@ color: var(--text-secondary, #5c5650); border-radius: 3px; white-space: nowrap; + margin-left: 0.375rem; } .selectiveTag { @@ -191,7 +201,7 @@ @media (max-width: 640px) { .row { flex-wrap: wrap; - padding: 0.75rem; + padding: 0.875rem; gap: 0.625rem; } @@ -203,7 +213,7 @@ white-space: normal; } - .line2 { + .line3 { gap: 0 1rem; } diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index 9db9f46..6f54582 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -1,10 +1,11 @@ /** * SecondarySchoolRow Component - * Three-line row for secondary school search results + * Four-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 + * Line 1: School name · Ofsted badge + * Line 2: School type · Age range · Gender · Sixth form · Admissions tag + * Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils + * Line 4: LA name · distance */ 'use client'; @@ -67,17 +68,14 @@ export function SecondarySchoolRow({ return (
- {/* Left: three content lines */} + {/* Left: four content lines */}
- {/* Line 1: School name + type + Ofsted badge */} + {/* Line 1: School name + Ofsted badge */}
{school.school_name} - {school.school_type && ( - {school.school_type} - )} {school.ofsted_grade && ( {OFSTED_LABELS[school.ofsted_grade]} @@ -90,8 +88,25 @@ export function SecondarySchoolRow({ )}
- {/* Line 2: KS4 stats */} + {/* Line 2: Context tags */}
+ {school.school_type && {school.school_type}} + {school.age_range && {school.age_range}} + {showGender && ( + {school.gender} + )} + {sixthForm && ( + Sixth form + )} + {admissionsTag && ( + + {admissionsTag} + + )} +
+ + {/* Line 3: KS4 stats */} +
{att8 != null ? att8.toFixed(1) : '—'} @@ -124,22 +139,11 @@ export function SecondarySchoolRow({ )}
- {/* Line 3: Location + tags */} -
+ {/* Line 4: Location + distance */} +
{school.local_authority && ( {school.local_authority} )} - {showGender && ( - {school.gender} - )} - {sixthForm && ( - Sixth form - )} - {admissionsTag && ( - - {admissionsTag} - - )} {isLocationSearch && school.distance != null && ( {school.distance.toFixed(1)} mi diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 10b97f1..a3ba806 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -273,6 +273,14 @@ export interface PaginationInfo { total_pages: number; } +export interface ResultFilters { + local_authorities: string[]; + school_types: string[]; + phases: string[]; + genders: string[]; + admissions_policies: string[]; +} + export interface SchoolsResponse { schools: School[]; page: number; @@ -280,6 +288,7 @@ export interface SchoolsResponse { total: number; total_pages: number; search_mode?: 'name' | 'location'; + result_filters?: ResultFilters; location_info?: { postcode: string; radius: number; @@ -399,6 +408,7 @@ export interface RankingsParams { year?: number; local_authority?: string; limit?: number; + phase?: string; } export interface ComparisonParams {