feat(ux): 8 UX improvements — simpler home, advanced filters, phase tabs, 4-line rows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 48s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 08:57:06 +01:00
parent e8175561d5
commit 1d22877aec
14 changed files with 735 additions and 408 deletions

View File

@@ -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)

View File

@@ -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}
/>
);
}

View File

@@ -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);

View File

@@ -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<string, ComparisonData> | 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<string, ComparisonData> = {};
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,6 +225,33 @@ export function ComparisonView({
</div>
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
{activeSchools.length === 0 ? (
<EmptyState
title={`No ${comparePhase} schools in your comparison`}
message={`Add ${comparePhase} schools from search results to compare them here.`}
action={{
label: '+ Add Schools',
onClick: () => setIsModalOpen(true),
}}
/>
) : (
<>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
@@ -169,46 +263,17 @@ export function ComparisonView({
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
<optgroup label="Expected Standard">
{metrics.filter(m => m.category === 'expected').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Higher Standard">
{metrics.filter(m => m.category === 'higher').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Progress Scores">
{metrics.filter(m => m.category === 'progress').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Average Scores">
{metrics.filter(m => m.category === 'average').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Gender Performance">
{metrics.filter(m => m.category === 'gender').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Equity (Disadvantaged)">
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="School Context">
{metrics.filter(m => m.category === 'context').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="3-Year Trends">
{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 (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
@@ -218,14 +283,14 @@ export function ComparisonView({
{/* Progress score explanation */}
{selectedMetric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
</p>
)}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{selectedSchools.map((school, index) => (
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolCard}
@@ -252,7 +317,7 @@ export function ComparisonView({
</div>
{/* Latest metric value */}
{comparisonData && comparisonData[school.urn] && (
{activeComparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
@@ -268,7 +333,7 @@ export function ComparisonView({
}}
/>
{(() => {
const yearlyData = comparisonData[school.urn].yearly_data;
const yearlyData = activeComparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1];
@@ -276,7 +341,6 @@ export function ComparisonView({
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')) {
@@ -294,25 +358,25 @@ export function ComparisonView({
</section>
{/* Comparison Chart */}
{comparisonData && Object.keys(comparisonData).length > 0 ? (
{Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={comparisonData}
comparisonData={activeComparisonData}
metric={selectedMetric}
metricLabel={metricLabel}
/>
</div>
</section>
) : selectedSchools.length > 0 ? (
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Table */}
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
@@ -320,7 +384,7 @@ export function ComparisonView({
<thead>
<tr>
<th>Year</th>
{selectedSchools.map((school) => (
{activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
@@ -329,8 +393,8 @@ export function ComparisonView({
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{year}</td>
{selectedSchools.map((school) => {
const schoolData = comparisonData[school.urn];
{activeSchools.map((school) => {
const schoolData = activeComparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year);
@@ -342,7 +406,6 @@ export function ComparisonView({
return <td key={school.urn}>-</td>;
}
// Format based on metric type
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
@@ -361,6 +424,8 @@ export function ComparisonView({
</div>
</section>
)}
</>
)}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />

View File

@@ -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;
}

View File

@@ -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 (
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
@@ -128,6 +146,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
)}
</form>
{!isHero && (
<div className={styles.advancedSection}>
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
>
Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
</button>
{filtersOpen && (
<div className={styles.filters}>
<select
value={currentLA}
@@ -136,10 +166,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All Local Authorities</option>
{filters.local_authorities.map((la) => (
<option key={la} value={la}>
{la}
</option>
{laOptions.map((la) => (
<option key={la} value={la}>{la}</option>
))}
</select>
@@ -150,14 +178,12 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All School Types</option>
{filters.school_types.map((type) => (
<option key={type} value={type}>
{type}
</option>
{typeOptions.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
{filters.phases && filters.phases.length > 0 && (
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
@@ -165,17 +191,15 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All Phases</option>
{filters.phases.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
{isSecondaryMode && (
<>
{filters.genders && filters.genders.length > 0 && (
{genderOptions.length > 0 && (
<select
value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)}
@@ -183,7 +207,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{filters.genders.map((g) => (
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option>
))}
</select>
@@ -196,11 +220,11 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form (1118)</option>
<option value="no">Without sixth form (1116)</option>
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
</select>
{filters.admissions_policies && filters.admissions_policies.length > 0 && (
{admissionsPolicyOptions.length > 0 && (
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
@@ -208,7 +232,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All admissions types</option>
{filters.admissions_policies.map((p) => (
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
@@ -222,6 +246,9 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
</button>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -108,6 +108,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<FilterBar
filters={filters}
isHero={!isSearchActive}
resultFilters={initialSchools.result_filters}
/>
{/* Discovery section shown on landing page before any search */}
@@ -257,7 +258,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<>
<div className={styles.schoolList}>
{sortedSchools.map((school) => (
isSecondaryView || school.attainment_8_score != null ? (
school.attainment_8_score != null ? (
<SecondarySchoolRow
key={school.urn}
school={school}

View File

@@ -22,6 +22,47 @@
line-height: 1.6;
}
/* 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);
}
/* Filters */
.filters {
background: var(--bg-card, white);

View File

@@ -1,6 +1,6 @@
/**
* RankingsView Component
* Client-side rankings interface with filters
* Client-side rankings interface with phase tabs and filters
*/
'use client';
@@ -12,6 +12,25 @@ import { formatPercentage, formatProgress } from '@/lib/utils';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.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 RankingsViewProps {
rankings: RankingEntry[];
filters: Filters;
@@ -19,6 +38,7 @@ interface RankingsViewProps {
selectedMetric: string;
selectedArea?: string;
selectedYear?: number;
selectedPhase?: string;
}
export function RankingsView({
@@ -28,12 +48,17 @@ export function RankingsView({
selectedMetric,
selectedArea,
selectedYear,
selectedPhase = 'primary',
}: RankingsViewProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { addSchool, isSelected } = useComparison();
const isPrimary = selectedPhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const updateFilters = (updates: Record<string, string | undefined>) => {
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 (
<div className={styles.container}>
{/* Header */}
@@ -84,10 +116,26 @@ export function RankingsView({
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()}
{!selectedArea && <span className={styles.limitNote}> showing top {rankings.length}</span>}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p>
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary (KS2)
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary (GCSE)
</button>
</div>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
@@ -107,46 +155,17 @@ export function RankingsView({
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.filterSelect}
>
<optgroup label="Expected Standard">
{metrics.filter(m => m.category === 'expected').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Higher Standard">
{metrics.filter(m => m.category === 'higher').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Progress Scores">
{metrics.filter(m => m.category === 'progress').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Average Scores">
{metrics.filter(m => m.category === 'average').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Gender Performance">
{metrics.filter(m => m.category === 'gender').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Equity (Disadvantaged)">
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="School Context">
{metrics.filter(m => m.category === 'context').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="3-Year Trends">
{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 (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
</div>
@@ -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}`),
}}
/>
) : (

View File

@@ -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;
}

View File

@@ -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 (
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: three content lines */}
{/* Left: four content lines */}
<div className={styles.rowContent}>
{/* Line 1: School name + type + Ofsted badge */}
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}>
{school.school_name}
</a>
{school.school_type && (
<span className={styles.schoolType}>{school.school_type}</span>
)}
{school.ofsted_grade && (
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
{OFSTED_LABELS[school.ofsted_grade]}
{school.ofsted_date && (
<span className={styles.ofstedDate}>
{' '}({new Date(school.ofsted_date).getFullYear()})
</span>
)}
</span>
)}
</div>
{/* Line 2: Key stats */}
{/* Line 2: Context tags */}
<div className={styles.line2}>
{school.school_type && <span>{school.school_type}</span>}
{school.age_range && <span>{school.age_range}</span>}
{showDenomination && <span>{school.religious_denomination}</span>}
{showGender && <span>{school.gender}</span>}
</div>
{/* Line 3: Key stats */}
<div className={styles.line3}>
{school.rwm_expected_pct != null ? (
<span className={styles.stat}>
<strong className={styles.statValue}>
@@ -123,8 +139,8 @@ export function SchoolRow({
)}
</div>
{/* Line 3: Location + distance */}
<div className={styles.line3}>
{/* Line 4: Location + distance */}
<div className={styles.line4}>
{school.local_authority && (
<span>{school.local_authority}</span>
)}
@@ -133,11 +149,6 @@ export function SchoolRow({
{school.distance.toFixed(1)} mi
</span>
)}
{!isLocationSearch &&
school.religious_denomination &&
school.religious_denomination !== 'Does not apply' && (
<span>{school.religious_denomination}</span>
)}
</div>
</div>

View File

@@ -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;
}

View File

@@ -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 (
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: three content lines */}
{/* Left: four content lines */}
<div className={styles.rowContent}>
{/* Line 1: School name + type + Ofsted badge */}
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}>
{school.school_name}
</a>
{school.school_type && (
<span className={styles.schoolType}>{school.school_type}</span>
)}
{school.ofsted_grade && (
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
{OFSTED_LABELS[school.ofsted_grade]}
@@ -90,8 +88,25 @@ export function SecondarySchoolRow({
)}
</div>
{/* Line 2: KS4 stats */}
{/* Line 2: Context tags */}
<div className={styles.line2}>
{school.school_type && <span>{school.school_type}</span>}
{school.age_range && <span>{school.age_range}</span>}
{showGender && (
<span className={styles.provisionTag}>{school.gender}</span>
)}
{sixthForm && (
<span className={styles.provisionTag}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
</span>
)}
</div>
{/* Line 3: KS4 stats */}
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValueLarge}>
{att8 != null ? att8.toFixed(1) : '—'}
@@ -124,22 +139,11 @@ export function SecondarySchoolRow({
)}
</div>
{/* Line 3: Location + tags */}
<div className={styles.line3}>
{/* Line 4: Location + distance */}
<div className={styles.line4}>
{school.local_authority && (
<span>{school.local_authority}</span>
)}
{showGender && (
<span className={styles.provisionTag}>{school.gender}</span>
)}
{sixthForm && (
<span className={styles.provisionTag}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
</span>
)}
{isLocationSearch && school.distance != null && (
<span className={styles.distanceBadge}>
{school.distance.toFixed(1)} mi

View File

@@ -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 {