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

@@ -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,208 +225,206 @@ export function ComparisonView({
</div>
</header>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
<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) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
</section>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
{/* 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.
</p>
)}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{selectedSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolCard}
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
{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}>
Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
<button
onClick={() => handleRemoveSchool(school.urn)}
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>
{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>
)}
</section>
{/* Latest metric value */}
{comparisonData && comparisonData[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 = comparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
{/* Progress score explanation */}
{selectedMetric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
</p>
)}
const latestData = yearlyData[yearlyData.length - 1];
const value = latestData[selectedMetric as keyof typeof latestData];
if (value === null || value === undefined) return '-';
// Format based on metric type
if (selectedMetric.includes('progress')) {
return formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
return formatPercentage(value as number);
} else {
return typeof value === 'number' ? value.toFixed(1) : String(value);
}
})()}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolCard}
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
>
<button
onClick={() => handleRemoveSchool(school.urn)}
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>
{/* 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>
</section>
</section>
{/* Comparison Chart */}
{comparisonData && Object.keys(comparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={comparisonData}
metric={selectedMetric}
metricLabel={metricLabel}
/>
</div>
</section>
) : selectedSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Chart */}
{Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={activeComparisonData}
metric={selectedMetric}
metricLabel={metricLabel}
/>
</div>
</section>
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Table */}
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
<table className={styles.comparisonTable}>
<thead>
<tr>
<th>Year</th>
{selectedSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{year}</td>
{selectedSchools.map((school) => {
const schoolData = comparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
{/* Comparison Table */}
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
<table className={styles.comparisonTable}>
<thead>
<tr>
<th>Year</th>
{activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{year}</td>
{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);
if (!yearData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year);
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) {
return <td key={school.urn}>-</td>;
}
if (value === null || value === undefined) {
return <td key={school.urn}>-</td>;
}
// Format based on metric type
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
displayValue = formatPercentage(value as number);
} else {
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
}
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
displayValue = formatPercentage(value as number);
} else {
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
}
return <td key={school.urn}>{displayValue}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
return <td key={school.urn}>{displayValue}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</>
)}
{/* School Search Modal */}

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,100 +146,109 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
)}
</form>
<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>
{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}
{!isHero && (
<div className={styles.advancedSection}>
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
>
<option value="">All Phases</option>
{filters.phases.map((p) => (
<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
Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
</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>
);
}

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) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
{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