The homepage made four statements that were not true, carried elements that
asked nothing of anyone, and had a hero illustration that broke in both the
places it had to work.
Claims, all verified against the code or the API:
* "24,000+ schools" (three places) against a real 27,230. The fact box meant
to show the live figure rendered its own fallback on every request, because
DataInfoResponse declared a `total_schools` field the API has never sent —
it sends `unique_schools`. The fetch succeeded; only that field was
undefined, so nothing threw and nothing failed. The interface, not the
code, was the thing that was wrong.
* "Up to three schools side by side" against MAX_SCHOOLS = 5, contradicting a
card 400px below it that correctly said five.
* "Class sizes" — data the codebase has never held. That copy line was the
only hit in a full-repo grep.
* Invented results and Ofsted grades attributed to two real, named schools
in the compare preview.
Also one feature, three words: Compare (nav), shortlist (footer), pin (cards).
Settled on Compare everywhere. And <title> was the bare string "Home".
Cut: the trust line (repeated the coverage figure one paragraph after the hero
gave it, behind three decorative dots), the "Start exploring" row (three links
to two destinations already in the nav), the six-row coverage table, and three
of the four countdown cards — which gave the page's largest numeral to dates up
to 245 days away, two of them offer days, which cannot be missed. All four
dates remain, at proportionate weight. Value-prop titles drop from <h2> to <p>;
they were outranking the page's real headings in the document outline.
Rhythm: the gaps between the seven landing bands were 24/32/24/16/48/32/16px,
each band setting its own margin, with four different section-header
treatments between them. The page container now owns one gap, and there is one
header pattern. An e2e test asserts the gaps are identical.
Illustration: it kept a fixed light palette in both themes, which left a pale
sky slab as the brightest object on a near-black page, out-shouting the H1 and
the search box. It now reads from --ill-* tokens with a dark re-grade. And the
hero slot ranges from 1.34:1 to 4.9:1 across breakpoints, which no single
composition survives under `slice` — at 860x176 a 540x520 scene shows only its
bottom 110 units, so the schoolhouse was cropped away entirely on phones,
leaving hills and a pin pointing at nothing. There are now two compositions,
each drawn against the crop window its own breakpoint produces, with CSS
showing one. Both are static server-rendered SVG.
The deadline bar renders on the server rather than on hydrate. The effect-based
version needed a reserved height, and one guessed number cannot cover a block
whose supporting line wraps differently at every width — measured, it was short
at all four, shifting the page up to 108px on a phone.
Verified on the built output through an offline render harness (no local
server): real compiled CSS, real rendered markup, four widths, both themes.
tsc clean, 159/159 unit tests, build green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
to HomeView via slot props so their JSX stays out of the client bundle.
Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
+ stale-while-revalidate, ETag generation, and 304 on If-None-Match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
These params were present in the URL but never passed to fetchSchools on
the server side, so the backend never applied the filters. Also include
them in hasSearchParams so filter-only searches trigger a fetch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sort was local state, lost on navigation. Now reads from
searchParams.get('sort') and pushes to URL on change.
'default' removes the param to keep URLs clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
/api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
tab hidden for secondary; admissions says Year 7 instead of Year 3;
history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
1. Show empty state by default on home page
- Don't fetch or display schools until user searches
- Show helpful message prompting users to search
- Only fetch schools when search params are present
2. Change distance search to miles
- Display 0.5, 1, and 2 mile options instead of km
- Convert miles to km when sending to API (backend expects km)
- Convert km back to miles for display in location banner
- Maintains backend compatibility while improving UX
3. Fix metric labels in rankings dropdown
- Backend returns 'name' and 'type' fields
- Frontend expects 'label' and 'format' fields
- Added transformation in fetchMetrics to map fields
- Dropdown now shows proper labels like "RWM Combined %"
instead of technical codes like "rwm_expected_pct"
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
In Next.js 15, searchParams is a Promise that must be awaited before
accessing its properties. The home page was directly accessing
searchParams.search, searchParams.local_authority, etc., which resulted
in all parameters being undefined. This caused all API calls to return
all schools regardless of search/filter parameters.
This fix brings the home page in line with the compare and rankings
pages, which already correctly await searchParams.
Fixes search, filter, and pagination functionality on the home page.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Backend returns filters directly at top level, not wrapped in 'filters' property.
Update FiltersResponse type and page components to match actual API response.
Fixes empty dropdowns for school types and local authorities.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add try-catch blocks to all page components
- Provide empty data fallbacks when API calls fail
- Use optional chaining for safer property access
- Log errors for debugging
Fixes 'Cannot read properties of undefined' errors.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router
- Add server-side rendering for all pages (Home, Compare, Rankings)
- Create individual school pages with dynamic routing (/school/[urn])
- Implement Chart.js and Leaflet map integrations
- Add comprehensive SEO with sitemap, robots.txt, and JSON-LD
- Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js)
- Update CI/CD pipeline to build both backend and frontend images
- Fix Dockerfile to include devDependencies for TypeScript compilation
- Add Jest testing configuration
- Implement performance optimizations (code splitting, caching)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>