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 .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .utils import clean_for_json 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 # SECURITY MIDDLEWARE & HELPERS
@@ -380,6 +391,15 @@ async def get_schools(
schools_df["school_type"].str.lower() == school_type.lower() 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 # Pagination
total = len(schools_df) total = len(schools_df)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
@@ -392,6 +412,7 @@ async def get_schools(
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"result_filters": result_filters,
"location_info": { "location_info": {
"postcode": postcode, "postcode": postcode,
"radius": radius * 1.60934, # Convert miles to km for frontend display "radius": radius * 1.60934, # Convert miles to km for frontend display
@@ -507,7 +528,11 @@ async def compare_schools(
"urn": urn, "urn": urn,
"school_name": latest.get("school_name", ""), "school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""), "local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""),
"address": latest.get("address", ""), "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), "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: 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] 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 [] genders = clean_filter_values(secondary_df["gender"]) 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 [] admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
return { return {
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()), "local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
"school_types": sorted(df["school_type"].dropna().unique().tolist()), "school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
"years": sorted(df["year"].dropna().unique().tolist()), "years": sorted(df["year"].dropna().unique().tolist()),
"phases": phases, "phases": phases,
"genders": genders, "genders": genders,
@@ -641,6 +666,9 @@ async def get_rankings(
local_authority: Optional[str] = Query( local_authority: Optional[str] = Query(
None, description="Filter by local authority", max_length=100 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.""" """Get school rankings by a specific metric."""
# Sanitize local authority input # Sanitize local authority input
@@ -670,6 +698,12 @@ async def get_rankings(
if local_authority: if local_authority:
df = df[df["local_authority"].str.lower() == local_authority.lower()] 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) # Sort and rank (exclude rows with no data for this metric)
df = df.dropna(subset=[metric]) df = df.dropna(subset=[metric])
total = len(df) total = len(df)

View File

@@ -12,6 +12,7 @@ interface RankingsPageProps {
metric?: string; metric?: string;
local_authority?: string; local_authority?: string;
year?: string; year?: string;
phase?: string;
}>; }>;
} }
@@ -25,9 +26,10 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function RankingsPage({ searchParams }: RankingsPageProps) { 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; const year = yearParam ? parseInt(yearParam) : undefined;
// Fetch rankings data with error handling // Fetch rankings data with error handling
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
local_authority, local_authority,
year, year,
limit: 100, limit: 100,
phase,
}), }),
fetchFilters(), fetchFilters(),
fetchMetrics(), fetchMetrics(),
@@ -54,6 +57,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
selectedMetric={metric} selectedMetric={metric}
selectedArea={local_authority} selectedArea={local_authority}
selectedYear={year} selectedYear={year}
selectedPhase={phase}
/> />
); );
} catch (error) { } catch (error) {
@@ -68,6 +72,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
selectedMetric={metric} selectedMetric={metric}
selectedArea={local_authority} selectedArea={local_authority}
selectedYear={year} 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 */ /* Metric Selector */
.metricSelector { .metricSelector {
background: var(--bg-card, white); background: var(--bg-card, white);

View File

@@ -1,6 +1,6 @@
/** /**
* ComparisonView Component * ComparisonView Component
* Client-side comparison interface with charts and tables * Client-side comparison interface with phase tabs, charts, and tables
*/ */
'use client'; 'use client';
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
import { SchoolSearchModal } from './SchoolSearchModal'; import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton'; 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 { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
import { fetchComparison } from '@/lib/api'; import { fetchComparison } from '@/lib/api';
import styles from './ComparisonView.module.css'; 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 { interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null; initialData: Record<string, ComparisonData> | null;
initialUrns: number[]; initialUrns: number[];
@@ -39,6 +58,7 @@ export function ComparisonView({
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData); const [comparisonData, setComparisonData] = useState(initialData);
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Seed context from initialData when component mounts and localStorage is empty // Seed context from initialData when component mounts and localStorage is empty
useEffect(() => { useEffect(() => {
@@ -84,6 +104,37 @@ export function ComparisonView({
} }
}, [selectedSchools, selectedMetric, pathname, searchParams, router]); }, [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) => { const handleMetricChange = (metric: string) => {
setSelectedMetric(metric); setSelectedMetric(metric);
}; };
@@ -100,6 +151,12 @@ export function ComparisonView({
} catch { /* fallback: do nothing */ } } 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 // Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric); const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || 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 // Get years for table
const years = const years =
comparisonData && Object.keys(comparisonData).length > 0 Object.keys(activeComparisonData).length > 0
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year) ? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: []; : [];
return ( return (
@@ -158,208 +225,206 @@ export function ComparisonView({
</div> </div>
</header> </header>
{/* Metric Selector */} {/* Phase Tabs */}
<section className={styles.metricSelector}> <div className={styles.phaseTabs}>
<label htmlFor="metric-select" className={styles.metricLabel}> <button
Select Metric: className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
</label> onClick={() => handlePhaseChange('primary')}
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
> >
<optgroup label="Expected Standard"> Primary ({primarySchools.length})
{metrics.filter(m => m.category === 'expected').map((metric) => ( </button>
<option key={metric.key} value={metric.key}>{metric.label}</option> <button
))} className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
</optgroup> onClick={() => handlePhaseChange('secondary')}
<optgroup label="Higher Standard"> >
{metrics.filter(m => m.category === 'higher').map((metric) => ( Secondary ({secondarySchools.length})
<option key={metric.key} value={metric.key}>{metric.label}</option> </button>
))} </div>
</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) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
</section>
{/* Progress score explanation */} {activeSchools.length === 0 ? (
{selectedMetric.includes('progress') && ( <EmptyState
<p className={styles.progressNote}> title={`No ${comparePhase} schools in your comparison`}
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average. message={`Add ${comparePhase} schools from search results to compare them here.`}
</p> action={{
)} label: '+ Add Schools',
onClick: () => setIsModalOpen(true),
{/* School Cards */} }}
<section className={styles.schoolsSection}> />
<div className={styles.schoolsGrid}> ) : (
{selectedSchools.map((school, index) => ( <>
<div {/* Metric Selector */}
key={school.urn} <section className={styles.metricSelector}>
className={styles.schoolCard} <label htmlFor="metric-select" className={styles.metricLabel}>
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }} Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
> >
<button {optgroups.map(({ label, category }) => {
onClick={() => handleRemoveSchool(school.urn)} const groupMetrics = filteredMetrics.filter(m => m.category === category);
className={styles.removeButton} if (groupMetrics.length === 0) return null;
aria-label={`Remove ${school.school_name}`} return (
title="Remove from comparison" <optgroup key={category} label={label}>
> {groupMetrics.map((metric) => (
× <option key={metric.key} value={metric.key}>{metric.label}</option>
</button> ))}
<h2 className={styles.schoolName}> </optgroup>
<a href={`/school/${school.urn}`}>{school.school_name}</a> );
</h2> })}
<div className={styles.schoolMeta}> </select>
{school.local_authority && ( {currentMetricDef?.description && (
<span className={styles.metaItem}>{school.local_authority}</span> <p className={styles.metricDescription}>{currentMetricDef.description}</p>
)} )}
{school.school_type && ( </section>
<span className={styles.metaItem}>{school.school_type}</span>
)}
</div>
{/* Latest metric value */} {/* Progress score explanation */}
{comparisonData && comparisonData[school.urn] && ( {selectedMetric.includes('progress') && (
<div className={styles.latestValue}> <p className={styles.progressNote}>
<div className={styles.latestLabel}>{metricLabel}</div> Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}> </p>
<span )}
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
background: CHART_COLORS[index % CHART_COLORS.length],
marginRight: '0.4rem',
verticalAlign: 'middle',
}}
/>
{(() => {
const yearlyData = comparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1]; {/* School Cards */}
const value = latestData[selectedMetric as keyof typeof latestData]; <section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
if (value === null || value === undefined) return '-'; {activeSchools.map((school, index) => (
<div
// Format based on metric type key={school.urn}
if (selectedMetric.includes('progress')) { className={styles.schoolCard}
return formatProgress(value as number); style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { >
return formatPercentage(value as number); <button
} else { onClick={() => handleRemoveSchool(school.urn)}
return typeof value === 'number' ? value.toFixed(1) : String(value); className={styles.removeButton}
} aria-label={`Remove ${school.school_name}`}
})()} title="Remove from comparison"
>
×
</button>
<h2 className={styles.schoolName}>
<a href={`/school/${school.urn}`}>{school.school_name}</a>
</h2>
<div className={styles.schoolMeta}>
{school.local_authority && (
<span className={styles.metaItem}>{school.local_authority}</span>
)}
{school.school_type && (
<span className={styles.metaItem}>{school.school_type}</span>
)}
</div> </div>
{/* Latest metric value */}
{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] }}>
<span
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
background: CHART_COLORS[index % CHART_COLORS.length],
marginRight: '0.4rem',
verticalAlign: 'middle',
}}
/>
{(() => {
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);
}
})()}
</div>
</div>
)}
</div> </div>
)} ))}
</div> </div>
))} </section>
</div>
</section>
{/* Comparison Chart */} {/* Comparison Chart */}
{comparisonData && Object.keys(comparisonData).length > 0 ? ( {Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}> <section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2> <h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<ComparisonChart <ComparisonChart
comparisonData={comparisonData} comparisonData={activeComparisonData}
metric={selectedMetric} metric={selectedMetric}
metricLabel={metricLabel} metricLabel={metricLabel}
/> />
</div> </div>
</section> </section>
) : selectedSchools.length > 0 ? ( ) : activeSchools.length > 0 ? (
<section className={styles.chartSection}> <section className={styles.chartSection}>
<LoadingSkeleton type="list" /> <LoadingSkeleton type="list" />
</section> </section>
) : null} ) : null}
{/* Comparison Table */} {/* Comparison Table */}
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && ( {Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}> <section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2> <h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}> <div className={styles.tableWrapper}>
<table className={styles.comparisonTable}> <table className={styles.comparisonTable}>
<thead> <thead>
<tr> <tr>
<th>Year</th> <th>Year</th>
{selectedSchools.map((school) => ( {activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th> <th key={school.urn}>{school.school_name}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{years.map((year) => ( {years.map((year) => (
<tr key={year}> <tr key={year}>
<td className={styles.yearCell}>{year}</td> <td className={styles.yearCell}>{year}</td>
{selectedSchools.map((school) => { {activeSchools.map((school) => {
const schoolData = comparisonData[school.urn]; const schoolData = activeComparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>; if (!schoolData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year); const yearData = schoolData.yearly_data.find((d) => d.year === year);
if (!yearData) return <td key={school.urn}>-</td>; if (!yearData) return <td key={school.urn}>-</td>;
const value = yearData[selectedMetric as keyof typeof yearData]; const value = yearData[selectedMetric as keyof typeof yearData];
if (value === null || value === undefined) { if (value === null || value === undefined) {
return <td key={school.urn}>-</td>; return <td key={school.urn}>-</td>;
} }
// Format based on metric type let displayValue: string;
let displayValue: string; if (selectedMetric.includes('progress')) {
if (selectedMetric.includes('progress')) { displayValue = formatProgress(value as number);
displayValue = formatProgress(value as number); } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { displayValue = formatPercentage(value as number);
displayValue = formatPercentage(value as number); } else {
} else { displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value); }
}
return <td key={school.urn}>{displayValue}</td>; return <td key={school.urn}>{displayValue}</td>;
})} })}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
)}
</>
)} )}
{/* School Search Modal */} {/* School Search Modal */}

View File

@@ -32,8 +32,12 @@
padding: 1.25rem 2.5rem; padding: 1.25rem 2.5rem;
} }
.heroMode .searchSection {
margin-bottom: 0;
}
.searchSection { .searchSection {
margin-bottom: 1rem; margin-bottom: 0;
} }
.omniBoxContainer { .omniBoxContainer {
@@ -154,3 +158,45 @@
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; 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 { useState, useCallback, useTransition, useRef, useEffect } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { isValidPostcode } from '@/lib/utils'; import { isValidPostcode } from '@/lib/utils';
import type { Filters } from '@/lib/types'; import type { Filters, ResultFilters } from '@/lib/types';
import styles from './FilterBar.module.css'; import styles from './FilterBar.module.css';
interface FilterBarProps { interface FilterBarProps {
filters: Filters; filters: Filters;
isHero?: boolean; isHero?: boolean;
resultFilters?: ResultFilters;
} }
export function FilterBar({ filters, isHero }: FilterBarProps) { export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -32,9 +33,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || ''; const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
const currentHasSixthForm = searchParams.get('has_sixth_form') || ''; 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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { 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))) && if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'INPUT' &&
document.activeElement?.tagName !== 'TEXTAREA' && 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 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 ( return (
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}> <div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
@@ -128,100 +146,109 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
)} )}
</form> </form>
<div className={styles.filters}> {!isHero && (
<select <div className={styles.advancedSection}>
value={currentLA} <button
onChange={(e) => handleFilterChange('local_authority', e.target.value)} type="button"
className={styles.filterSelect} className={styles.advancedToggle}
disabled={isPending} onClick={() => setFiltersOpen(v => !v)}
>
<option value="">All Local Authorities</option>
{filters.local_authorities.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
<select
value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All School Types</option>
{filters.school_types.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
{filters.phases && filters.phases.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
> >
<option value="">All Phases</option> Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
{filters.phases.map((p) => ( <span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
)}
{isSecondaryMode && (
<>
{filters.genders && filters.genders.length > 0 && (
<select
value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{filters.genders.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option>
))}
</select>
)}
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
className={styles.filterSelect}
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>
</select>
{filters.admissions_policies && filters.admissions_policies.length > 0 && (
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All admissions types</option>
{filters.admissions_policies.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
</>
)}
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear Filters
</button> </button>
)}
</div> {filtersOpen && (
<div className={styles.filters}>
<select
value={currentLA}
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>{la}</option>
))}
</select>
<select
value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All School Types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All Phases</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<select
value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option>
))}
</select>
)}
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
</select>
{admissionsPolicyOptions.length > 0 && (
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
</>
)}
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear Filters
</button>
)}
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/** /**
* RankingsView Component * RankingsView Component
* Client-side rankings interface with filters * Client-side rankings interface with phase tabs and filters
*/ */
'use client'; 'use client';
@@ -12,6 +12,25 @@ import { formatPercentage, formatProgress } from '@/lib/utils';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import styles from './RankingsView.module.css'; 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 { interface RankingsViewProps {
rankings: RankingEntry[]; rankings: RankingEntry[];
filters: Filters; filters: Filters;
@@ -19,6 +38,7 @@ interface RankingsViewProps {
selectedMetric: string; selectedMetric: string;
selectedArea?: string; selectedArea?: string;
selectedYear?: number; selectedYear?: number;
selectedPhase?: string;
} }
export function RankingsView({ export function RankingsView({
@@ -28,12 +48,17 @@ export function RankingsView({
selectedMetric, selectedMetric,
selectedArea, selectedArea,
selectedYear, selectedYear,
selectedPhase = 'primary',
}: RankingsViewProps) { }: RankingsViewProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { addSchool, isSelected } = useComparison(); 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 updateFilters = (updates: Record<string, string | undefined>) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
@@ -48,6 +73,11 @@ export function RankingsView({
router.push(`${pathname}?${params.toString()}`); 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) => { const handleMetricChange = (metric: string) => {
updateFilters({ metric }); updateFilters({ metric });
}; };
@@ -63,7 +93,6 @@ export function RankingsView({
const handleAddToCompare = (ranking: RankingEntry) => { const handleAddToCompare = (ranking: RankingEntry) => {
addSchool({ addSchool({
...ranking, ...ranking,
// Ensure required School fields are present
address: null, address: null,
postcode: null, postcode: null,
latitude: null, latitude: null,
@@ -77,6 +106,9 @@ export function RankingsView({
const isProgressScore = selectedMetric.includes('progress'); const isProgressScore = selectedMetric.includes('progress');
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate'); 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 ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Header */} {/* Header */}
@@ -84,10 +116,26 @@ export function RankingsView({
<h1>School Rankings</h1> <h1>School Rankings</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()} 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> </p>
</header> </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 && ( {currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p> <p className={styles.metricDescription}>{currentMetricDef.description}</p>
)} )}
@@ -107,46 +155,17 @@ export function RankingsView({
onChange={(e) => handleMetricChange(e.target.value)} onChange={(e) => handleMetricChange(e.target.value)}
className={styles.filterSelect} className={styles.filterSelect}
> >
<optgroup label="Expected Standard"> {optgroups.map(({ label, category }) => {
{metrics.filter(m => m.category === 'expected').map((metric) => ( const groupMetrics = filteredMetrics.filter(m => m.category === category);
<option key={metric.key} value={metric.key}>{metric.label}</option> if (groupMetrics.length === 0) return null;
))} return (
</optgroup> <optgroup key={category} label={label}>
<optgroup label="Higher Standard"> {groupMetrics.map((metric) => (
{metrics.filter(m => m.category === 'higher').map((metric) => ( <option key={metric.key} value={metric.key}>{metric.label}</option>
<option key={metric.key} value={metric.key}>{metric.label}</option> ))}
))} </optgroup>
</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) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
</select> </select>
</div> </div>
@@ -199,7 +218,7 @@ export function RankingsView({
message="Try selecting a different metric, area, or year." message="Try selecting a different metric, area, or year."
action={{ action={{
label: 'Clear filters', 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: 1px solid var(--border-color, #e5dfd5);
border-left: 3px solid transparent; border-left: 3px solid transparent;
border-radius: 8px; border-radius: 8px;
padding: 0.75rem 1rem; padding: 1rem 1.25rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
animation: rowFadeIn 0.3s ease-out both; animation: rowFadeIn 0.3s ease-out both;
} }
@@ -32,10 +32,10 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.35rem;
} }
/* Line 1: name + type */ /* Line 1: name + ofsted */
.line1 { .line1 {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -59,15 +59,24 @@
color: var(--accent-coral, #e07256); 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; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
white-space: nowrap;
flex-shrink: 0;
} }
/* Line 2: stats */ .line2 span:not(:last-child)::after {
.line2 { content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
}
/* Line 3: stats */
.line3 {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@@ -107,17 +116,17 @@
.trendDown { color: var(--accent-coral, #e07256); } .trendDown { color: var(--accent-coral, #e07256); }
.trendStable { color: var(--text-muted, #8a847a); } .trendStable { color: var(--text-muted, #8a847a); }
/* Line 3: location */ /* Line 4: location */
.line3 { .line4 {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 0; gap: 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
} }
.line3 span:not(:last-child)::after { .line4 span:not(:last-child)::after {
content: '·'; content: '·';
margin: 0 0.4rem; margin: 0 0.4rem;
color: var(--border-color, #e5dfd5); color: var(--border-color, #e5dfd5);
@@ -162,6 +171,10 @@
line-height: 1.4; line-height: 1.4;
} }
.ofstedDate {
font-weight: 400;
}
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); } .ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; } .ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; } .ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
@@ -171,7 +184,7 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.row { .row {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.75rem; padding: 0.875rem;
gap: 0.625rem; gap: 0.625rem;
} }
@@ -183,7 +196,7 @@
white-space: normal; white-space: normal;
} }
.line2 { .line3 {
gap: 0 1rem; gap: 0 1rem;
} }

View File

@@ -1,10 +1,11 @@
/** /**
* SchoolRow Component * SchoolRow Component
* Three-line row for school search results * Four-line row for primary school search results
* *
* Line 1: School name · School type * Line 1: School name · Ofsted badge
* Line 2: R,W&M % · Progress score · Pupil count * Line 2: School type · Age range · Denomination · Gender
* Line 3: Local authority · Distance * Line 3: R,W&M % · Progress score · Pupil count
* Line 4: Local authority · Distance
*/ */
import type { School } from '@/lib/types'; 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 ( return (
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}> <div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: three content lines */} {/* Left: four content lines */}
<div className={styles.rowContent}> <div className={styles.rowContent}>
{/* Line 1: School name + type + Ofsted badge */} {/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}> <div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}> <a href={`/school/${school.urn}`} className={styles.schoolName}>
{school.school_name} {school.school_name}
</a> </a>
{school.school_type && (
<span className={styles.schoolType}>{school.school_type}</span>
)}
{school.ofsted_grade && ( {school.ofsted_grade && (
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}> <span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
{OFSTED_LABELS[school.ofsted_grade]} {OFSTED_LABELS[school.ofsted_grade]}
{school.ofsted_date && (
<span className={styles.ofstedDate}>
{' '}({new Date(school.ofsted_date).getFullYear()})
</span>
)}
</span> </span>
)} )}
</div> </div>
{/* Line 2: Key stats */} {/* Line 2: Context tags */}
<div className={styles.line2}> <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 ? ( {school.rwm_expected_pct != null ? (
<span className={styles.stat}> <span className={styles.stat}>
<strong className={styles.statValue}> <strong className={styles.statValue}>
@@ -123,8 +139,8 @@ export function SchoolRow({
)} )}
</div> </div>
{/* Line 3: Location + distance */} {/* Line 4: Location + distance */}
<div className={styles.line3}> <div className={styles.line4}>
{school.local_authority && ( {school.local_authority && (
<span>{school.local_authority}</span> <span>{school.local_authority}</span>
)} )}
@@ -133,11 +149,6 @@ export function SchoolRow({
{school.distance.toFixed(1)} mi {school.distance.toFixed(1)} mi
</span> </span>
)} )}
{!isLocationSearch &&
school.religious_denomination &&
school.religious_denomination !== 'Does not apply' && (
<span>{school.religious_denomination}</span>
)}
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@
border: 1px solid var(--border-color, #e5dfd5); border: 1px solid var(--border-color, #e5dfd5);
border-left: 3px solid transparent; border-left: 3px solid transparent;
border-radius: 8px; border-radius: 8px;
padding: 0.75rem 1rem; padding: 1rem 1.25rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
animation: rowFadeIn 0.3s ease-out both; animation: rowFadeIn 0.3s ease-out both;
} }
@@ -32,10 +32,10 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.35rem;
} }
/* Line 1: name + type + ofsted */ /* Line 1: name + ofsted */
.line1 { .line1 {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -59,15 +59,24 @@
color: var(--accent-coral, #e07256); 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; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
white-space: nowrap;
flex-shrink: 0;
} }
/* Line 2: KS4 stats */ .line2 > span:not(.provisionTag):not(:last-child)::after {
.line2 { content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
}
/* Line 3: KS4 stats */
.line3 {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@@ -109,17 +118,17 @@
.deltaPositive { color: #3c8c3c; } .deltaPositive { color: #3c8c3c; }
.deltaNegative { color: var(--accent-coral, #e07256); } .deltaNegative { color: var(--accent-coral, #e07256); }
/* Line 3: location + tags */ /* Line 4: location + distance */
.line3 { .line4 {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 0; gap: 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
} }
.line3 span:not(:last-child)::after { .line4 span:not(:last-child)::after {
content: '·'; content: '·';
margin: 0 0.4rem; margin: 0 0.4rem;
color: var(--border-color, #e5dfd5); color: var(--border-color, #e5dfd5);
@@ -144,6 +153,7 @@
color: var(--text-secondary, #5c5650); color: var(--text-secondary, #5c5650);
border-radius: 3px; border-radius: 3px;
white-space: nowrap; white-space: nowrap;
margin-left: 0.375rem;
} }
.selectiveTag { .selectiveTag {
@@ -191,7 +201,7 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.row { .row {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.75rem; padding: 0.875rem;
gap: 0.625rem; gap: 0.625rem;
} }
@@ -203,7 +213,7 @@
white-space: normal; white-space: normal;
} }
.line2 { .line3 {
gap: 0 1rem; gap: 0 1rem;
} }

View File

@@ -1,10 +1,11 @@
/** /**
* SecondarySchoolRow Component * 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 1: School name · Ofsted badge
* Line 2: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils * Line 2: School type · Age range · Gender · Sixth form · Admissions tag
* Line 3: LA name · gender tag · sixth form tag · admissions tag · distance * Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
* Line 4: LA name · distance
*/ */
'use client'; 'use client';
@@ -67,17 +68,14 @@ export function SecondarySchoolRow({
return ( return (
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}> <div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: three content lines */} {/* Left: four content lines */}
<div className={styles.rowContent}> <div className={styles.rowContent}>
{/* Line 1: School name + type + Ofsted badge */} {/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}> <div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}> <a href={`/school/${school.urn}`} className={styles.schoolName}>
{school.school_name} {school.school_name}
</a> </a>
{school.school_type && (
<span className={styles.schoolType}>{school.school_type}</span>
)}
{school.ofsted_grade && ( {school.ofsted_grade && (
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}> <span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
{OFSTED_LABELS[school.ofsted_grade]} {OFSTED_LABELS[school.ofsted_grade]}
@@ -90,8 +88,25 @@ export function SecondarySchoolRow({
)} )}
</div> </div>
{/* Line 2: KS4 stats */} {/* Line 2: Context tags */}
<div className={styles.line2}> <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}> <span className={styles.stat}>
<strong className={styles.statValueLarge}> <strong className={styles.statValueLarge}>
{att8 != null ? att8.toFixed(1) : '—'} {att8 != null ? att8.toFixed(1) : '—'}
@@ -124,22 +139,11 @@ export function SecondarySchoolRow({
)} )}
</div> </div>
{/* Line 3: Location + tags */} {/* Line 4: Location + distance */}
<div className={styles.line3}> <div className={styles.line4}>
{school.local_authority && ( {school.local_authority && (
<span>{school.local_authority}</span> <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 && ( {isLocationSearch && school.distance != null && (
<span className={styles.distanceBadge}> <span className={styles.distanceBadge}>
{school.distance.toFixed(1)} mi {school.distance.toFixed(1)} mi

View File

@@ -273,6 +273,14 @@ export interface PaginationInfo {
total_pages: number; total_pages: number;
} }
export interface ResultFilters {
local_authorities: string[];
school_types: string[];
phases: string[];
genders: string[];
admissions_policies: string[];
}
export interface SchoolsResponse { export interface SchoolsResponse {
schools: School[]; schools: School[];
page: number; page: number;
@@ -280,6 +288,7 @@ export interface SchoolsResponse {
total: number; total: number;
total_pages: number; total_pages: number;
search_mode?: 'name' | 'location'; search_mode?: 'name' | 'location';
result_filters?: ResultFilters;
location_info?: { location_info?: {
postcode: string; postcode: string;
radius: number; radius: number;
@@ -399,6 +408,7 @@ export interface RankingsParams {
year?: number; year?: number;
local_authority?: string; local_authority?: string;
limit?: number; limit?: number;
phase?: string;
} }
export interface ComparisonParams { export interface ComparisonParams {