From 3d24050d1171544500385a3bcd7a07f87aabc383 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 23 Mar 2026 21:31:28 +0000 Subject: [PATCH] feat(ux): implement comprehensive UX audit fixes across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nextjs-app/app/page.tsx | 6 +- .../components/ComparisonToast.module.css | 73 +++++++++++-- nextjs-app/components/ComparisonToast.tsx | 30 ++++-- .../components/ComparisonView.module.css | 39 +++++++ nextjs-app/components/ComparisonView.tsx | 81 +++++++++++--- nextjs-app/components/FilterBar.module.css | 25 ++++- nextjs-app/components/FilterBar.tsx | 42 +++++++- nextjs-app/components/Footer.tsx | 6 -- nextjs-app/components/HomeView.module.css | 101 ++++++++++++++++++ nextjs-app/components/HomeView.tsx | 63 +++++++++-- nextjs-app/components/Navigation.tsx | 6 -- nextjs-app/components/RankingsView.module.css | 36 +++++++ nextjs-app/components/RankingsView.tsx | 25 ++++- nextjs-app/components/SchoolCard.module.css | 29 ++++- nextjs-app/components/SchoolCard.tsx | 28 ++--- .../components/SchoolDetailView.module.css | 29 +++++ nextjs-app/components/SchoolDetailView.tsx | 43 ++++---- 17 files changed, 564 insertions(+), 98 deletions(-) diff --git a/nextjs-app/app/page.tsx b/nextjs-app/app/page.tsx index b1c80f6..0f6d083 100644 --- a/nextjs-app/app/page.tsx +++ b/nextjs-app/app/page.tsx @@ -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) { ); } catch (error) { @@ -76,6 +77,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { ); } diff --git a/nextjs-app/components/ComparisonToast.module.css b/nextjs-app/components/ComparisonToast.module.css index 9aba0c7..ac14f1f 100644 --- a/nextjs-app/components/ComparisonToast.module.css +++ b/nextjs-app/components/ComparisonToast.module.css @@ -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; } diff --git a/nextjs-app/components/ComparisonToast.tsx b/nextjs-app/components/ComparisonToast.tsx index fe01b66..5e55d8e 100644 --- a/nextjs-app/components/ComparisonToast.tsx +++ b/nextjs-app/components/ComparisonToast.tsx @@ -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 (
-
- {selectedSchools.length} - +
+ + {selectedSchools.length} {selectedSchools.length === 1 ? 'school' : 'schools'} selected
+
+ {selectedSchools.map(school => ( +
+ + {school.school_name.length > 28 ? school.school_name.slice(0, 28) + '…' : school.school_name} + + +
+ ))} +
- - - Compare Now - + + Compare Now
diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index 9ef51d6..6c62b58 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -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 { diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index 168d2a5..ae55b79 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -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), }} /> + + setIsModalOpen(false)} />
); } @@ -123,9 +146,15 @@ export function ComparisonView({ Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}

- +
+ + +
@@ -181,17 +210,32 @@ export function ComparisonView({ ))} + {currentMetricDef?.description && ( +

{currentMetricDef.description}

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

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

+ )} + {/* School Cards */}
- {selectedSchools.map((school) => ( -
+ {selectedSchools.map((school, index) => ( +
@@ -211,7 +255,18 @@ export function ComparisonView({ {comparisonData && comparisonData[school.urn] && (
{metricLabel}
-
+
+ {(() => { const yearlyData = comparisonData[school.urn].yearly_data; if (yearlyData.length === 0) return '-'; @@ -252,7 +307,7 @@ export function ComparisonView({
) : selectedSchools.length > 0 ? (
-
Loading comparison data...
+
) : null} diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 274a2d0..faf7758 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -150,7 +150,7 @@ .omniBoxContainer { flex-direction: column; } - + .searchButton { width: 100%; } @@ -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; +} diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 16adb6c..423ff14 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -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,16 +16,33 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { const pathname = usePathname(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); + const inputRef = useRef(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); 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) => { 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) {
setOmniValue(e.target.value)} @@ -87,6 +104,23 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { {isPending ?
: 'Search'}
+ {currentPostcode && ( +
+ + +
+ )}
diff --git a/nextjs-app/components/Footer.tsx b/nextjs-app/components/Footer.tsx index 793fff2..19b48ad 100644 --- a/nextjs-app/components/Footer.tsx +++ b/nextjs-app/components/Footer.tsx @@ -33,12 +33,6 @@ export function Footer() { Data Source -
  • - Privacy Policy -
  • -
  • - Terms of Use -
  • diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css index a3bc041..3955163 100644 --- a/nextjs-app/components/HomeView.module.css +++ b/nextjs-app/components/HomeView.module.css @@ -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); +} diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index 8bb502e..b65b01c 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -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(null); + const [sortOrder, setSortOrder] = useState('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 (
    {/* Combined Hero + Search and Filters */} -
    -

    Compare Primary School Performance

    -

    Search and compare KS2 results for thousands of schools across England

    -
    - + {!isSearchActive && ( +
    +

    Compare Primary School Performance

    +

    Search and compare KS2 results for thousands of schools across England

    +
    + )} + + {/* Discovery section shown on landing page before any search */} + {!isSearchActive && initialSchools.schools.length === 0 && ( +
    + {totalSchools &&

    {totalSchools.toLocaleString()}+ primary schools across England

    } +

    Try searching for a school name, or enter a postcode to find schools near you.

    +
    + Quick searches: + {['Manchester', 'Bristol', 'Leeds', 'Birmingham'].map(city => ( + {city} + ))} +
    +
    + )} + {/* Location Info Banner with View Toggle */} {isLocationSearch && initialSchools.location_info && (
    @@ -106,6 +132,22 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) { {initialSchools.total.toLocaleString()} school {initialSchools.total !== 1 ? 's' : ''} found + +
    + )} + + {isSearchActive && ( +
    + {searchParams.get('search') && Search: {searchParams.get('search')} { e.preventDefault(); }}>×} + {searchParams.get('local_authority') && {searchParams.get('local_authority')}} + {searchParams.get('school_type') && {searchParams.get('school_type')}} + {searchParams.get('postcode') && Near {searchParams.get('postcode')} ({(parseFloat(searchParams.get('radius') || '1.6') / 1.60934).toFixed(1)} mi)}
    )} @@ -132,8 +174,8 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
    {initialSchools.schools.map((school) => ( -
    - {initialSchools.schools.map((school) => ( + {sortedSchools.map((school) => ( s.urn === school.urn)} /> ))} diff --git a/nextjs-app/components/Navigation.tsx b/nextjs-app/components/Navigation.tsx index 10ac348..46a9737 100644 --- a/nextjs-app/components/Navigation.tsx +++ b/nextjs-app/components/Navigation.tsx @@ -36,12 +36,6 @@ export function Navigation() {