feat(ux): implement comprehensive UX audit fixes across all pages
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m8s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

Addresses 28 issues identified in UX audit (P0–P3 severity):

P0 — Critical:
- Fix compare URL sharing: seed ComparisonContext from SSR initialData
  when localStorage is empty, making /compare?urns=... links shareable
- Remove permanently broken "Avg. Scaled Score" column from school
  detail historical data table

P1 — High priority:
- Add radius selector (0.5–10 mi) to postcode search in FilterBar
- Make Add to Compare a toggle (remove) on SchoolCards
- Hide hero title/description once a search is active
- Show school count + quick-search prompts on empty landing page
- Compare empty state opens in-page school search modal directly
- Remove URN from school detail header (irrelevant to end users)
- Move map above performance chart in school detail page
- Add ← Back navigation to school detail page
- Add sort controls to search results (RWM%, distance, A–Z)
- Show metric descriptions below metric selector
- Expand ComparisonToast to list school names with per-school remove
- Add progress score explainer (0 = national average) throughout

P2 — Medium:
- Remove console.log statements from ComparisonView
- Colour-code comparison school cards to match chart line colours
- Replace plain loading text with LoadingSkeleton in ComparisonView
- Rankings empty state uses shared EmptyState component
- Rankings year filter shows actual year e.g. "2023 (Latest)"
- Rankings subtitle shows top-N count
- Add View link alongside Add button in rankings table
- Remove placeholder Privacy Policy / Terms links from footer
- Replace untappable 10px info icons with visible metric hint text
- Show active filter chips in search results header

P3 — Polish:
- Remove redundant "Home" nav link (logo already links home)
- Add / and Ctrl+K keyboard shortcut to focus search input
- Add Share button to compare page (copies URL to clipboard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:31:28 +00:00
parent d4abb56c22
commit 3d24050d11
17 changed files with 564 additions and 98 deletions

View File

@@ -3,7 +3,7 @@
* Main landing page with school search and browsing
*/
import { fetchSchools, fetchFilters } from '@/lib/api';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { HomeView } from '@/components/HomeView';
interface HomePageProps {
@@ -43,7 +43,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// Fetch data on server with error handling
try {
const filtersData = await fetchFilters();
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
// Only fetch schools if there are search parameters
let schoolsData;
@@ -66,6 +66,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<HomeView
initialSchools={schoolsData}
filters={filtersData || { local_authorities: [], school_types: [], years: [] }}
totalSchools={dataInfo?.total_schools ?? null}
/>
);
} catch (error) {
@@ -76,6 +77,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<HomeView
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
filters={{ local_authorities: [], school_types: [], years: [] }}
totalSchools={null}
/>
);
}

View File

@@ -20,14 +20,15 @@
.toastContent {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
flex-direction: column;
gap: 0;
padding: 1rem 1.25rem;
background: var(--bg-accent, #1a1612);
color: var(--text-inverse, #faf7f2);
border-radius: 50px;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(26, 22, 18, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 260px;
}
.toastInfo {
@@ -59,6 +60,8 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding-top: 0.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btnClear {
@@ -93,6 +96,65 @@
background: var(--bg-secondary, #f3ede4);
}
.toastHeader {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.toastTitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-inverse, #faf7f2);
}
.schoolList {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
max-height: 120px;
overflow-y: auto;
}
.schoolItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.25rem 0.375rem;
background: rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm, 4px);
}
.schoolName {
font-size: 0.8rem;
color: var(--text-inverse, #faf7f2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.removeSchoolBtn {
background: none;
border: none;
color: rgba(250, 247, 242, 0.5);
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
line-height: 1;
flex-shrink: 0;
transition: color 0.2s ease;
}
.removeSchoolBtn:hover {
color: var(--text-inverse, #faf7f2);
}
@media (max-width: 640px) {
.toastContainer {
bottom: 1.5rem;
@@ -100,8 +162,7 @@
}
.toastContent {
flex-direction: column;
gap: 1rem;
gap: 0;
border-radius: 16px;
padding: 1.25rem;
}

View File

@@ -7,7 +7,7 @@ import { usePathname } from 'next/navigation';
import styles from './ComparisonToast.module.css';
export function ComparisonToast() {
const { selectedSchools, clearAll } = useComparison();
const { selectedSchools, clearAll, removeSchool } = useComparison();
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
@@ -25,19 +25,29 @@ export function ComparisonToast() {
return (
<div className={styles.toastContainer}>
<div className={styles.toastContent}>
<div className={styles.toastInfo}>
<span className={styles.toastBadge}>{selectedSchools.length}</span>
<span className={styles.toastText}>
<div className={styles.toastHeader}>
<span className={styles.toastTitle}>
<span className={styles.toastBadge}>{selectedSchools.length}</span>
{selectedSchools.length === 1 ? 'school' : 'schools'} selected
</span>
</div>
<div className={styles.schoolList}>
{selectedSchools.map(school => (
<div key={school.urn} className={styles.schoolItem}>
<span className={styles.schoolName} title={school.school_name}>
{school.school_name.length > 28 ? school.school_name.slice(0, 28) + '…' : school.school_name}
</span>
<button
onClick={() => removeSchool(school.urn)}
className={styles.removeSchoolBtn}
aria-label={`Remove ${school.school_name}`}
>×</button>
</div>
))}
</div>
<div className={styles.toastActions}>
<button onClick={clearAll} className={styles.btnClear}>
Clear
</button>
<Link href="/compare" className={styles.btnCompare}>
Compare Now
</Link>
<button onClick={clearAll} className={styles.btnClear}>Clear all</button>
<Link href="/compare" className={styles.btnCompare}>Compare Now</Link>
</div>
</div>
</div>

View File

@@ -58,6 +58,7 @@
margin-bottom: 2rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
@@ -346,6 +347,44 @@
margin: 0 auto 1.5rem;
}
.metricDescription {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
max-width: 600px;
flex-basis: 100%;
margin-top: 0.25rem;
}
.progressNote {
background: var(--bg-secondary);
border-left: 3px solid var(--accent-teal);
padding: 0.75rem 1rem;
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.shareButton {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color, #e0ddd8);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition);
}
.shareButton:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
/* Responsive Design */
@media (max-width: 768px) {
.headerContent {

View File

@@ -11,8 +11,9 @@ import { useComparison } from '@/hooks/useComparison';
import { ComparisonChart } from './ComparisonChart';
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
import { fetchComparison } from '@/lib/api';
import styles from './ComparisonView.module.css';
@@ -32,11 +33,25 @@ export function ComparisonView({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool } = useComparison();
const { selectedSchools, removeSchool, addSchool, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData);
const [shareConfirm, setShareConfirm] = useState(false);
// Seed context from initialData when component mounts and localStorage is empty
useEffect(() => {
if (!isInitialized) return;
if (selectedSchools.length === 0 && initialUrns.length > 0 && initialData) {
initialUrns.forEach(urn => {
const data = initialData[String(urn)];
if (data?.school_info) {
addSchool(data.school_info);
}
});
}
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync URL with selected schools
useEffect(() => {
@@ -56,10 +71,8 @@ export function ComparisonView({
// Fetch comparison data
if (selectedSchools.length > 0) {
console.log('Fetching comparison data for URNs:', urns);
fetchComparison(urns, { cache: 'no-store' })
.then((data) => {
console.log('Comparison data received:', data);
setComparisonData(data.comparison);
})
.catch((err) => {
@@ -79,6 +92,14 @@ export function ComparisonView({
removeSchool(urn);
};
const handleShare = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ }
};
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
@@ -98,10 +119,12 @@ export function ComparisonView({
title="No schools selected"
message="Add schools from the home page or search to start comparing."
action={{
label: 'Browse Schools',
onClick: () => router.push('/'),
label: '+ Add Schools to Compare',
onClick: () => setIsModalOpen(true),
}}
/>
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
}
@@ -123,9 +146,15 @@ export function ComparisonView({
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
</p>
</div>
<button onClick={() => setIsModalOpen(true)} className={styles.addButton}>
+ Add School
</button>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setIsModalOpen(true)} className={styles.addButton}>
+ Add School
</button>
<button onClick={handleShare} className={styles.shareButton} title="Copy comparison link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
{shareConfirm ? 'Copied!' : 'Share'}
</button>
</div>
</div>
</header>
@@ -181,17 +210,32 @@ export function ComparisonView({
))}
</optgroup>
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
</section>
{/* 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) => (
<div key={school.urn} className={styles.schoolCard}>
{selectedSchools.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"
title="Remove from comparison"
>
×
</button>
@@ -211,7 +255,18 @@ export function ComparisonView({
{comparisonData && comparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
<div className={styles.latestNumber}>
<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 '-';
@@ -252,7 +307,7 @@ export function ComparisonView({
</section>
) : selectedSchools.length > 0 ? (
<section className={styles.chartSection}>
<div className={styles.loadingMessage}>Loading comparison data...</div>
<LoadingSkeleton type="list" />
</section>
) : null}

View File

@@ -163,3 +163,26 @@
min-width: 100%;
}
}
.radiusWrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.radiusLabel {
font-size: 0.875rem;
color: var(--text-secondary);
white-space: nowrap;
}
.radiusSelect {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0ddd8);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useTransition } from 'react';
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';
@@ -16,9 +16,11 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const currentSearch = searchParams.get('search') || '';
const currentPostcode = searchParams.get('postcode') || '';
const currentRadius = searchParams.get('radius') || '1.6';
const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue);
@@ -26,6 +28,21 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || '';
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' &&
document.activeElement?.tagName !== 'SELECT') {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const updateURL = useCallback((updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
@@ -52,8 +69,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
}
if (isValidPostcode(omniValue)) {
// Default to 1 mile radius (approx 1.6 km)
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: '1.6', search: '' });
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: currentRadius || '1.6', search: '' });
} else {
updateURL({ search: omniValue.trim(), postcode: '', radius: '' });
}
@@ -77,6 +93,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
<div className={styles.omniBoxContainer}>
<input
ref={inputRef}
type="search"
value={omniValue}
onChange={(e) => setOmniValue(e.target.value)}
@@ -87,6 +104,23 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
{isPending ? <div className={styles.spinner}></div> : 'Search'}
</button>
</div>
{currentPostcode && (
<div className={styles.radiusWrapper}>
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })}
className={styles.radiusSelect}
disabled={isPending}
>
<option value="0.8">0.5 miles</option>
<option value="1.6">1 mile</option>
<option value="4.8">3 miles</option>
<option value="8.0">5 miles</option>
<option value="16.0">10 miles</option>
</select>
</div>
)}
</form>
<div className={styles.filters}>

View File

@@ -33,12 +33,6 @@ export function Footer() {
Data Source
</a>
</li>
<li>
<span className={styles.linkDisabled}>Privacy Policy</span>
</li>
<li>
<span className={styles.linkDisabled}>Terms of Use</span>
</li>
</ul>
</div>

View File

@@ -539,3 +539,104 @@
display: none;
}
}
.discoverySection {
padding: 2rem var(--page-padding, 2rem);
text-align: center;
}
.discoveryCount {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.discoveryCount strong {
color: var(--text-primary);
font-size: 1.25rem;
}
.discoveryHints {
color: var(--text-muted);
margin-bottom: 1.25rem;
}
.quickSearches {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.quickSearchLabel {
font-size: 0.875rem;
color: var(--text-muted);
}
.quickSearchChip {
padding: 0.375rem 0.875rem;
background: var(--bg-card);
border: 1px solid var(--border-color, #e0ddd8);
border-radius: 999px;
font-size: 0.875rem;
color: var(--text-secondary);
text-decoration: none;
transition: all var(--transition);
}
.quickSearchChip:hover {
background: var(--accent-coral);
color: white;
border-color: var(--accent-coral);
}
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0 0 1rem;
}
.sortSelect {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0ddd8);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
}
.activeFilters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filterChip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color, #e0ddd8);
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.chipRemove {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
line-height: 1;
transition: color var(--transition, 0.2s ease);
}
.chipRemove:hover {
color: var(--text-primary);
}

View File

@@ -19,13 +19,15 @@ import styles from './HomeView.module.css';
interface HomeViewProps {
initialSchools: SchoolsResponse;
filters: Filters;
totalSchools?: number | null;
}
export function HomeView({ initialSchools, filters }: HomeViewProps) {
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
const searchParams = useSearchParams();
const { addSchool, selectedSchools } = useComparisonContext();
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
const [sortOrder, setSortOrder] = useState<string>('default');
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
@@ -36,19 +38,43 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
setSelectedMapSchool(null);
}, [resultsView, searchParams]);
const sortedSchools = [...initialSchools.schools].sort((a, b) => {
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
return 0;
});
return (
<div className={styles.homeView}>
{/* Combined Hero + Search and Filters */}
<div className={styles.heroSection}>
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
</div>
{!isSearchActive && (
<div className={styles.heroSection}>
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
</div>
)}
<FilterBar
filters={filters}
isHero={!isSearchActive}
/>
{/* Discovery section shown on landing page before any search */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.discoverySection}>
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary schools across England</p>}
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
<div className={styles.quickSearches}>
<span className={styles.quickSearchLabel}>Quick searches:</span>
{['Manchester', 'Bristol', 'Leeds', 'Birmingham'].map(city => (
<a key={city} href={`/?search=${city}`} className={styles.quickSearchChip}>{city}</a>
))}
</div>
</div>
)}
{/* Location Info Banner with View Toggle */}
{isLocationSearch && initialSchools.location_info && (
<div className={styles.locationBannerWrapper}>
@@ -106,6 +132,22 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
{initialSchools.total.toLocaleString()} school
{initialSchools.total !== 1 ? 's' : ''} found
</h2>
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
<option value="default">Sort: Relevance</option>
<option value="rwm_desc">Highest RWM%</option>
<option value="rwm_asc">Lowest RWM%</option>
{isLocationSearch && <option value="distance">Nearest first</option>}
<option value="name_asc">Name AZ</option>
</select>
</div>
)}
{isSearchActive && (
<div className={styles.activeFilters}>
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({(parseFloat(searchParams.get('radius') || '1.6') / 1.60934).toFixed(1)} mi)</span>}
</div>
)}
@@ -163,11 +205,12 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
/* List View Layout */
<>
<div className={styles.grid}>
{initialSchools.schools.map((school) => (
{sortedSchools.map((school) => (
<SchoolCard
key={school.urn}
school={school}
onAddToCompare={addSchool}
onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
/>
))}

View File

@@ -36,12 +36,6 @@ export function Navigation() {
</Link>
<nav className={styles.nav}>
<Link
href="/"
className={isActive('/') ? `${styles.navLink} ${styles.active}` : styles.navLink}
>
Home
</Link>
<Link
href="/compare"
className={isActive('/compare') ? `${styles.navLink} ${styles.active}` : styles.navLink}

View File

@@ -350,3 +350,39 @@
min-width: 100px;
}
}
.limitNote {
color: var(--text-muted);
font-weight: 400;
}
.metricDescription {
font-size: 0.875rem;
color: var(--text-secondary);
margin: -1rem 0 1.5rem;
max-width: 700px;
}
.progressHint {
font-size: 0.8rem;
color: var(--text-muted);
margin: -1rem 0 1.5rem;
font-style: italic;
}
.viewButton {
display: inline-block;
padding: 0.25rem 0.625rem;
font-size: 0.8rem;
color: var(--text-secondary);
border: 1px solid var(--border-color, #e0ddd8);
border-radius: var(--radius-sm, 4px);
text-decoration: none;
margin-right: 0.375rem;
transition: all var(--transition, 0.2s ease);
}
.viewButton:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}

View File

@@ -9,6 +9,7 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.module.css';
interface RankingsViewProps {
@@ -83,9 +84,17 @@ 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>}
</p>
</header>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
{isProgressScore && (
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
)}
{/* Filters */}
<section className={styles.filters}>
<div className={styles.filterGroup}>
@@ -170,7 +179,9 @@ export function RankingsView({
onChange={(e) => handleYearChange(e.target.value)}
className={styles.filterSelect}
>
<option value="">Latest</option>
<option value="">
{filters.years.length > 0 ? `${Math.max(...filters.years)} (Latest)` : 'Latest'}
</option>
{filters.years.map((year) => (
<option key={year} value={year}>
{year}
@@ -183,9 +194,14 @@ export function RankingsView({
{/* Rankings Table */}
<section className={styles.rankingsSection}>
{rankings.length === 0 ? (
<div className={styles.noResults}>
<p>No rankings available for the selected filters.</p>
</div>
<EmptyState
title="No rankings found"
message="Try selecting a different metric, area, or year."
action={{
label: 'Clear filters',
onClick: () => router.push(pathname),
}}
/>
) : (
<div className={styles.tableWrapper}>
<table className={styles.rankingsTable}>
@@ -242,6 +258,7 @@ export function RankingsView({
<strong>{displayValue}</strong>
</td>
<td className={styles.actionCell}>
<a href={`/school/${ranking.urn}`} className={styles.viewButton}>View</a>
<button
onClick={() => handleAddToCompare(ranking)}
disabled={alreadyInComparison}

View File

@@ -187,11 +187,34 @@
transform: translateY(0);
}
.btnPrimary.btnAdded {
.btnRemove {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
text-align: center;
text-decoration: none;
transition: all 0.2s ease;
background: var(--bg-secondary, #f3ede4);
color: var(--text-secondary, #5c564d);
border: 1px solid var(--border-color, #e5dfd5);
cursor: default;
border: 1px solid var(--border-color, #e0ddd8);
}
.btnRemove:hover {
background: var(--border-color, #e0ddd8);
color: var(--text-primary, #1a1612);
transform: translateY(-1px);
}
.metricHint {
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
display: block;
margin-top: 1px;
font-weight: 400;
}
@media (max-width: 640px) {

View File

@@ -11,12 +11,13 @@ import styles from './SchoolCard.module.css';
interface SchoolCardProps {
school: School;
onAddToCompare?: (school: School) => void;
onRemoveFromCompare?: (urn: number) => void;
showDistance?: boolean;
distance?: number;
isInCompare?: boolean;
}
export function SchoolCard({ school, onAddToCompare, showDistance, distance, isInCompare = false }: SchoolCardProps) {
export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDistance, distance, isInCompare = false }: SchoolCardProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const trendColor = getTrendColor(trend);
@@ -51,9 +52,9 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance, isI
<div className={styles.metrics}>
{school.rwm_expected_pct !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel} title="Percentage of pupils achieving the expected standard in Reading, Writing, and Maths">
<span className={styles.metricLabel}>
RWM Expected
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span className={styles.metricHint}>% meeting expected standard</span>
</span>
<div className={styles.metricValue}>
<strong>{formatPercentage(school.rwm_expected_pct)}</strong>
@@ -91,9 +92,9 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance, isI
{school.reading_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Reading. >0 is above average.">
<span className={styles.metricLabel}>
Reading
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span className={styles.metricHint}>progress score (0 = avg)</span>
</span>
<strong>{formatProgress(school.reading_progress)}</strong>
</div>
@@ -101,9 +102,9 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance, isI
{school.writing_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Writing. >0 is above average.">
<span className={styles.metricLabel}>
Writing
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span className={styles.metricHint}>progress score (0 = avg)</span>
</span>
<strong>{formatProgress(school.writing_progress)}</strong>
</div>
@@ -111,9 +112,9 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance, isI
{school.maths_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Maths. >0 is above average.">
<span className={styles.metricLabel}>
Maths
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span className={styles.metricHint}>progress score (0 = avg)</span>
</span>
<strong>{formatProgress(school.maths_progress)}</strong>
</div>
@@ -122,16 +123,15 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance, isI
)}
<div className={styles.actions}>
<Link href={`/school/${school.urn}`} className={styles.btnSecondary}>
<Link href={`/school/${school.urn}`} className={styles.btnPrimary}>
View Details
</Link>
{onAddToCompare && (
<button
onClick={() => onAddToCompare(school)}
className={`${styles.btnPrimary} ${isInCompare ? styles.btnAdded : ''}`}
disabled={isInCompare}
onClick={() => isInCompare ? onRemoveFromCompare?.(school.urn) : onAddToCompare(school)}
className={isInCompare ? styles.btnRemove : styles.btnSecondary}
>
{isInCompare ? 'Added ✓' : 'Add to Compare'}
{isInCompare ? '✓ Remove' : 'Add to Compare'}
</button>
)}
</div>

View File

@@ -395,3 +395,32 @@
padding: 0.5rem 0.375rem;
}
}
.backNav {
padding: 1rem var(--page-padding, 2rem);
padding-bottom: 0;
}
.backButton {
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
padding: 0.375rem 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: color var(--transition);
}
.backButton:hover {
color: var(--text-primary);
}
.progressNote {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -5,6 +5,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { PerformanceChart } from './PerformanceChart';
import { SchoolMap } from './SchoolMap';
@@ -19,6 +20,7 @@ interface SchoolDetailViewProps {
}
export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: SchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
@@ -36,6 +38,11 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
return (
<div className={styles.container}>
{/* Back Navigation */}
<div className={styles.backNav}>
<button onClick={() => router.back()} className={styles.backButton}> Back</button>
</div>
{/* Header Section */}
<header className={styles.header}>
<div className={styles.headerContent}>
@@ -52,9 +59,6 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
{schoolInfo.school_type}
</span>
)}
<span className={styles.metaItem}>
URN: {schoolInfo.urn}
</span>
</div>
{schoolInfo.address && (
<p className={styles.address}>
@@ -137,6 +141,23 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
)}
</div>
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
<p className={styles.progressNote}>Progress scores: 0 = national average. Positive = above average, negative = below average.</p>
)}
</section>
)}
{/* Map */}
{schoolInfo.latitude && schoolInfo.longitude && (
<section className={styles.mapSection}>
<h2 className={styles.sectionTitle}>Location</h2>
<div className={styles.mapContainer}>
<SchoolMap
schools={[schoolInfo]}
center={[schoolInfo.latitude, schoolInfo.longitude]}
zoom={15}
/>
</div>
</section>
)}
@@ -269,20 +290,6 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
</section>
)}
{/* Map */}
{schoolInfo.latitude && schoolInfo.longitude && (
<section className={styles.mapSection}>
<h2 className={styles.sectionTitle}>Location</h2>
<div className={styles.mapContainer}>
<SchoolMap
schools={[schoolInfo]}
center={[schoolInfo.latitude, schoolInfo.longitude]}
zoom={15}
/>
</div>
</section>
)}
{/* All Years Data Table */}
{yearlyData.length > 0 && (
<section className={styles.historySection}>
@@ -297,7 +304,6 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
<th>Avg. Scaled Score</th>
</tr>
</thead>
<tbody>
@@ -309,7 +315,6 @@ export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: School
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
<td>-</td>
</tr>
))}
</tbody>