M1: admissions rounds are now selected by the active phase tab
(admissionsForPhase) — an all-through school's Year 7 round no longer
masquerades as Reception odds beside pure primaries; honest per-cell and
section fallbacks name the round (Reception / Year 7).
M2: Ofsted sentinel codes (9 = not applicable) never render as judgement
chips, and the sixth-form judgement — previously dropped — now renders
for schools that have one.
M3: 'What this means' is phase- and type-aware: selective schools get
entrance-test framing, secondary faith schools a faith-criteria note, and
the primaries' distance template never appears on the secondary tab
(admissions_policy now exposed in compare school_info).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Opening a compare link while localStorage held a different basket raced a
fetch for the OLD school set against the URL's SSR data; the stale
response replaced comparisonData, so no active school had data and every
section (including the trends chart) vanished until a hard refresh.
Responses from superseded effect runs are now dropped, successful ones
merge instead of replace, and the URL-seed effect re-runs when a
client-side navigation changes ?urns=.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
'COMPARING / 3 primary schools' — 0.72rem uppercase eyebrow over a
0.95rem semibold count, sized to sit alongside the 0.92rem chip names
without dominating. Desktop only; mobile pills unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
The bar was a plain flex row while sections use a 200px-label + per-school
grid, so chips drifted off the columns they label. The bar now shares the
sections' grid template (via --school-count) at the same 761px breakpoint;
verified pixel-exact against live section columns (236/615/994). Mobile
pills unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
The Navigation header is position:sticky top:0 (z-index 1000). The school
bar was also sticky top:0 (z-index 10), so when scrolled it pinned at the
same top:0 *behind* the header — on mobile 57 of its 72px were covered,
leaving only a sliver, so you couldn't see which schools were selected.
Offset the bar's sticky top to the header height (65px desktop, 57px
mobile — the Navigation breakpoint is also 640px) so it pins just below.
e2e guard asserts the bar's sticky offset is at least the header height
(verified it fails against the pre-fix build).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
ComparisonChart runs Chart.js with maintainAspectRatio:false, so it sizes
to its container's height — which must be definite. TrendsExplorer gave
.chartBox a min-height, which doesn't resolve the chart wrapper's
height:100%, so Chart.js fell back to its ~150px default: a squashed
8.6:1 sliver that didn't match the mockups. Set a definite height (420px
desktop, 360px mobile where the chips row sits above the canvas).
Verified on staging by patching the live height: canvas went from
1287x150 to 1287x392 (desktop) / 284 (mobile) — proper ~3:1 proportions
matching the mockup, with the England dashed line, COVID/2021-22 gap and
table all reading correctly.
An e2e guard asserts the trends canvas is taller than 220px so the
squash can't regress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
The compare page's phase tabs put all-through schools (which carry KS4
data) on the secondary tab, so comparing an all-through school with a
pure primary splits them across tabs and only the active tab renders its
link. The test picked the first two 'primary' search hits without
guaranteeing same phase, so it flaked whenever a search returned an
all-through school first (e.g. URN 137306). Now selects two pure-Primary
URNs via the API — deterministic and data-invariant.
Verified against staging: was a 15.6s timeout, now passes in ~1.8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Schools without KS2/KS4 results (special post-16 institutions, sixth-form
centres, PRUs, new schools) come back from the marts LEFT JOIN with NaN in
every numeric column. school_info passed those raw pandas values straight
into JSONResponse, which renders with allow_nan=False, so the detail
endpoint 500d and the frontend turned that into a 404 on every such SEO
landing page.
Run school_info values through convert_to_native (the same treatment
yearly_data already gets), add backend unit tests plus a pytest step in PR
checks, and an e2e journey that finds a results-less school via the search
API and asserts its page renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The geolocation shortcut lived in the discovery strip below the results,
away from the search. Move it directly under the hero search input, paired
with the postcode hint, so the two ways to find nearby schools ("type a
postcode" / "use my location") read as one idea and are visible at first
glance.
- FilterBar gains optional onNearMe/geoState/geoError props and renders the
teal "Use my location" pill (with spinner + error) in hero mode; the
geolocation flow itself still lives in HomeView.
- Remove the now-duplicate near-me button and its dead CSS from the
discovery section.
- Refresh the search hint copy to pair with the button.
- e2e: assert the "use my location" shortcut renders in the hero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The results-view map's fullscreen button called requestFullscreen(),
which iOS Safari doesn't implement (fullscreen is video-only there), so
tapping it did nothing on iPhones — the same gap already fixed for the
school hero map.
When the Fullscreen API is missing or its promise rejects, fall back to
a fixed-position overlay (.fsFallback, z-index 5000) driven by state,
locking body scroll while open. Leaflet re-measures on window resize, so
dispatch a resize when fullscreen toggles (the CSS overlay fires none) or
the map would fill only part of the screen. Native fullscreen is
unchanged.
New e2e journey deletes Element.requestFullscreen on a mobile viewport,
opens the results map fullscreen, and asserts the exit control appears
then releases; it fails against current production, reproducing the bug.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test picked the first two /school/ links from a 'primary' search and
asserted exactly two mobile chips. But a 'primary' search can return
all-through schools (e.g. 'Hessle High School and Penshurst Primary')
that classify as secondary, so the two picks can split across phases —
the active phase then holds one school and the chips are correctly gated
out (they need ≥2 in the active phase), while the canvas still shows one
line. That's a test artefact, not a bug.
Pick three schools instead: across two phases the auto-selected majority
phase always holds ≥2, so the chip legend is guaranteed. Assert ≥2 chips
(the majority may be 2 or 3). Verified against staging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.
- Auto-fit the y-axis to the data on all viewports (computeYBounds in
lib/utils: padded + min-span for percentages, symmetric around 0 for
progress, fitted for scores; negative pct-named trend metrics are not
zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
dot + name) that doubles as tap-to-focus — highlights one school's
line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
whose integer-like URN keys enumerate in ascending numeric order,
desyncing line colours from card colours; the chart now receives the
ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.
Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS Safari has no Element.requestFullscreen (fullscreen is video-only),
so tapping the map band or 'View on map' silently did nothing on
iPhones. Fall back to a fixed-position CSS overlay driven by state when
the Fullscreen API is missing or its promise rejects, locking body
scroll while open. The Leaflet map already re-measures via the shared
isFullscreen flag. New e2e journey simulates the iOS condition by
deleting the API and asserts the overlay opens and closes; it fails
against the current production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Staging doesn't always carry the full data history, so selecting the
oldest year legitimately returns no rows and fails the promotion gate.
Select the most recent explicit year instead: the default view already
proved it has rows, so an empty table after selecting it can only mean
the year query param was rejected — the regression this test guards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rankings endpoint validated year with le=2100, but the database
stores academic-year codes like 201819, so any explicit year selection
returned a 422 and the rankings page rendered its empty state. Widen
the bound to cover the codes and extend the e2e journey to pick a
specific year and assert the table stays populated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admissions card stacks year/trend views in one grid cell and keeps the
inactive view visibility:hidden; its canvas is first in the DOM. Use
canvas:visible instead of scrolling. Verified: full suite passes against prod.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
- pr-checks.yml: PR gate — frontend typecheck+jest, backend import smoke,
image builds (no push), Claude AI review posted as PR comment (severe
findings block merge)
- deploy.yml (replaces build-and-push.yml): merge to main builds+pushes
images tagged sha-<sha>/staging, deploys the staging Portainer stack via
webhook, runs Playwright E2E journeys against staging, then retags the
verified images :prod (previous kept as :prod-previous) and deploys prod
- docker-compose.portainer.staging.yml: second Portainer stack — :staging
images, sc_staging_* names, own macvlan IPs, Airflow on 8081; data
bootstrapped from source via the staging Airflow DAGs
- prod compose now pins :prod instead of :latest (only the promotion step
moves it; :latest is no longer published)
- e2e/: 6 Playwright journeys (search, postcode, detail, compare, rankings)
driven by BASE_URL — the promotion gate
- scripts/ci/ai_review.py: Claude review with structured JSON findings
- docs/DEPLOY.md: full SDLC doc incl. one-time setup checklist and rollback
- replaced removed 'next lint' with tsc typecheck; fixed stale jest tests
(slug URLs, N/A formatting, stable trend, fake-timer setup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL