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

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