Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5 792766d308 fix(ci): make a failed release check say what it actually saw
The staging poller swallowed every failure identically, so a run that
timed out told us only that the expected release never appeared — not
whether the proxy refused us, the endpoint was down, or the containers
were still serving an older build. The public staging proxy also answers
403 to urllib's default user agent while the release endpoint is healthy,
which looked exactly like a deployment that never arrived.

Identify the poller, and report each distinct observation once: HTTP
status, connection failure type, invalid JSON, or the release identities
actually reported. The timeout error carries the last observation and the
identity it wanted. Responses and the base URL stay out of the logs —
only validated sha/build_id fields are echoed back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:01:59 +01:00
TudorandClaude Opus 5 5ad1cbfb53 fix(api): bound the search candidate set instead of draining Typesense
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 39s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 6m44s
Fetching every match kept scoped searches correct but left the number of
round trips in the caller's hands: a one-letter query, or a deliberately
broad one, walked the whole collection a page at a time.

Cap the candidate set at 1,000 URNs — four pages — and return the
relevance-ordered prefix when the ceiling is hit. That is still far more
than one page, so the API's own authority, phase and postcode filters
keep the matches they need, while latency and upstream load stay bounded.
A capped query is logged so a genuinely truncated search is visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:21:45 +01:00
TudorandClaude Opus 5 0c901cd0d1 feat(ci): gate promotion on the image set that actually passed E2E
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 6m3s
Staging health polling asked only whether something answered HTTP 200 at
the base URL. It could not tell the new deployment from the old one, so
journeys could pass against the previous release, and concurrent merges
could move the staging tags underneath a run in flight.

Each staging run now mints a build ID and stamps all three images with
the commit and that ID, as labels and — for frontend and backend — as a
build-time JSON file that environment overrides cannot rewrite.
/release.json reports both identities uncached, and scripts/ci/release.py
polls for the expected pair before and after the journeys. Only then are
the captured build digests tagged verified-<sha>.

Promotion resolves those verified tags to immutable digests, revalidates
their labels, and refuses a mixed or incomplete set before any :prod tag
moves. The whole staging workflow shares one concurrency group with
cancellation disabled, so releases serialise.

The scripts are stdlib-only and unit-tested against mocked registry and
HTTP behaviour; PR checks now run the pipeline and CI suites too. The
runbook records what this cannot prove locally, and that the first
rollout needs a commit built by this workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:17:50 +01:00
TudorandClaude Opus 5 7b41218e6e fix(web): show an outage as an outage, and drop superseded fetches
The home page caught every fetch failure and rendered its empty state,
so a backend outage looked like a site with no schools in it. School
pages turned any error into notFound(), which told visitors — and
crawlers — that a real school had ceased to exist. Place fetches did the
same by returning [] and null. Failures now reach a retryable error
boundary; only a genuine 404 still calls notFound().

"Load more" and the map fetch resolved against whatever state existed
when they returned, so results from an abandoned search appended
themselves to the new ones. Each fetch now carries an AbortController
and checks that its search scope is still current before touching state.
The map only records its cache key on success, so a failed load retries
instead of pinning the stale marker set.

Jest ignored .next/, whose build output otherwise shadowed real suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:17:18 +01:00
TudorandClaude Opus 5 9b75f54206 fix(api): publish a validated dataset, and stop truncating search
Reload cleared the caches first and rebuilt afterwards, so any failure
left the API serving nothing, and requests arriving mid-reload saw a
half-swapped state. It now builds and validates the replacement frames,
place registry, reverse index and sitemaps off the request loop, then
publishes them in one synchronous step under a lock. A failed reload
returns 503 and keeps the previous data. Sitemap regeneration takes the
same path rather than clearing the live registry up front.

Typesense search returned at most one page of hits and used an empty
list for both "no matches" and "search is down", so a genuine empty
result silently fell back to substring matching. It now pages through
every candidate and returns None only on failure; the fallback matches
literally, since a query containing regex metacharacters used to throw.

Empty datasets answer 503 rather than 200-with-nothing or a misleading
404, so callers can tell an outage from an absent school. Adds
/api/release, which reports the build identity baked into the image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:16:58 +01:00
TudorandClaude Opus 5 38bc17cab3 feat(search): validate the index before the alias points at it
The old sync created a collection, imported batches without reading a
single import response, and swapped the alias regardless. A partial
import published a half-empty index, and two overlapping DAG runs could
prune each other's collections.

Publication now checks every import response and the final document
count before upserting the alias, and holds a session-scoped advisory
lock across the read and the publish so concurrent runs serialise.
Cleanup keeps the previous collection as a rollback pointer and is
best-effort: an uncertain alias response must never delete what might
still be live.

Also parses the Typesense URL properly instead of splitting on colons,
which mangled any host carrying a scheme and a default port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:16:36 +01:00
TudorandClaude Opus 5 1d8858fbda chore: remove the code the legacy CSV importer left behind
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m2s
`backend/migration.py` and `scripts/migrate_csv_to_db.py` import `School`,
`SchoolResult`, `init_db` and `set_db_schema_version` — names that no longer
exist. `scripts/geocode_schools.py` imports the same removed ORM model. None of
the three can be imported against the current backend, so they were not dormant
utilities anyone could fall back on; they were files that would fail on the
first line. `backend/version.py` existed only to hand `SCHEMA_VERSION` to that
importer, and the FastAPI lifespan performs no version-triggered import.

Three symbols go with them, each confirmed to have no caller: the unvectorised
`haversine_distance`, superseded by the inline NumPy calculation in search;
`fetcher`, an SWR helper for a dependency this project does not install; and
`kmToMiles`. `calculateDistance` stays — CutoffMapPanel uses it.

Two comments pointed at `migrate_csv_to_db.py --drop` to explain why Payload
owns its own schema. The reason survives the script: blog content must stay
clear of the school marts and Airflow's metadata. Reworded rather than deleted,
so the constraint keeps its justification.

docs/LEGACY_CODE.md records what was removed and where to find it in history. It
also records what was deliberately *not* removed, which is the more useful half:
unused UI components awaiting a design decision, manual data utilities whose
operators a repository search cannot see, and fallbacks that look obsolete but
are load-bearing — `data_loader.py`'s older-mart branches, the generated GIAS
dictionary copies, and the `legacy`-named dbt models that annual DAG selectors
explicitly include. A zero-import count is evidence, not a verdict.

The scripts that fetch DfE CSVs are marked historical and kept, pending
confirmation that nobody runs them by hand.

Checked: 190 backend tests, 429 frontend tests, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan
2026-09-14 23:01:22 +01:00
TudorandClaude Opus 5 eaf5e5d180 docs: describe the system that exists, not the one we started with
The README still opened on "Primary School Compass", a KS2 tool for Wandsworth
and Merton served by FastAPI and vanilla JavaScript with Chart.js. Every layer
of that sentence is now wrong: coverage is England-wide across KS2, KS4,
all-through and post-16, Next.js owns the public UI, and school data comes from
dbt-built `marts.*` rather than CSVs loaded at startup. The setup instructions
walked a reader into a virtualenv and a CSV import that cannot build the current
schema, so following the docs produced an empty database and a wrong mental
model at the same time.

Replace the narrative docs with two reference documents that were checked
against the code: docs/ARCHITECTURE.md for request flow, data ownership, the
backend/frontend module boundaries and the real publication sequence, and
docs/DEVELOPMENT.md for the checks that actually run, including the container
and CI version skew that makes "just run pytest" misleading.

The env examples drifted the same way. ALLOWED_ORIGINS is a JSON array, not a
comma-separated list; the frontend needs FASTAPI_URL, DATABASE_URL and
PAYLOAD_SECRET, none of which were documented; and RATE_LIMIT_BURST,
DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE were presented as tuning controls the routes
do not consult. Each is now stated as it behaves.

MIGRATION_SUMMARY.md keeps its content but gains a banner, because it reads like
setup instructions and is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan
2026-09-14 23:01:15 +01:00
tudor be780ebe13 Merge pull request 'fix(seo): declare the share card, which the route group stopped inheriting' (#146) from fix/og-image-route-group into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m21s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m58s
Reviewed-on: #146
2026-09-14 21:03:14 +00:00
TudorandClaude Opus 5 b6c2cd5116 fix(seo): declare the share card, which the route group stopped inheriting
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
The staging E2E gate's one failure. Every link to the site pasted into
a chat has been rendering bare.

Staging serves og:title, og:description, og:url, og:site_name, og:type
and twitter:card, and no og:image at all. So metadata from the layout
reaches the page; only the file convention does not.

app/opengraph-image.tsx does work — _not-found, which lives in the app
root segment, carries an og:image from it in the build output. It does
not reach the site's pages, which live in the (frontend) route group
whose own layout.tsx is the root layout. The icon conventions are not
affected: /icon.png and /apple-icon.png are both linked correctly on
the same page, verified against staging. The asymmetry is the whole
bug, and it arrived with the route-group split that Payload required.

The file stays at the app root. Moving metadata files into a route
group is what drops /robots.txt and hashes /icon.png, which CLAUDE.md
records and which this must not undo — the build still emits all four
of /robots.txt, /icon.png, /apple-icon.png and /opengraph-image. The
root layout points at the route instead, and metadataBase makes it
absolute, which the journey needs since it calls new URL() on the value.

twitter.images is set for the same reason: the card is declared
summary_large_image, and claiming a large-image card while supplying no
image is worse than claiming a summary card.

Checked before fixing that og:image was the only broken assertion in
that journey: the test aborts at line 911, so its apple-touch-icon and
maskable-icon assertions had never run. All four of those assets return
200 image/png from staging, so this does not simply move the failure
further down the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 21:54:32 +01:00
tudor dc79d653e5 Merge pull request 'feat(seo): link school pages into the location layer' (#145) from feat/school-page-place-links into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m29s
Reviewed-on: #145
2026-09-14 20:29:48 +00:00
TudorandClaude Opus 5 b0d5334e06 perf(places): index the reverse lookup, and isolate the registry in tests
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m47s
Two review findings, both confirmed before fixing.

The client fixture in test_school_details.py patched load_school_data
but not _place_registry, which is a module-level cache. A probe settled
it rather than an argument: poisoning the global with a registry built
from data the fixture never saw, then issuing the fixture's own
request, returned that other dataset's places. So the new
`places == []` assertion was satisfied by a stale registry exactly as
well as by the fixture's own data, and proved nothing. Every other test
that touches place data already reset it; the fixture predates places
existing and was never updated. It resets it now.

places_for_urn walked every place in the registry and did a tuple
membership test against each, on /api/schools/{urn}, the site's
highest-traffic endpoint. It now reads a dict built once per registry.
Measured against a synthetic corpus of 27k schools in 1,650 places:
0.118ms per request becomes 0.0001ms, with the index built once in
21ms. Production carries ~5,000 places, so the scan there is larger
again. The absolute saving per request is small; the point is that it
is repeated on every school page view and costs nothing to remove.

The index is cached against the registry by identity rather than
behind a second flag. Anything that drops _place_registry — every test
that touches place data does — gets a fresh registry object, which no
longer matches what the index was built from, so the index rebuilds
with it. A separate _place_index = None would be one more thing to
forget, and a stale reverse index is precisely the first finding's bug
wearing a different hat.

That invalidation has its own test, and the test was checked by
breaking the identity check: five tests fail without it, so three
existing ones were already relying on it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 21:22:39 +01:00
TudorandClaude Opus 5 d65eb58883 fix(seo): build the phase links the docstring already promised
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m48s
Review caught _places_payload documenting a `phase_url` the function
never returned. The docstring was not stray prose: the approved design
included the phase variant — "Primary schools in Beccles" was one of
its four example links — and it was dropped during implementation
without being mentioned. Deleting the sentence would have closed the
report while losing the feature, so the links are built instead.

These are the pages that most needed them. ~950 phase variants were
once reachable by nothing at all: absent from every sitemap and
unlinked from the place page. "Primary schools in brentwood" is the
query they exist to answer.

Membership is read from the registry's own `phase_urns` rather than
re-derived from the school's phase string. The registry already decides
which phases a place publishes and which schools are listed on each, so
asking it is both shorter and the only way the link cannot disagree
with the page it points at. It also means outcodes need no special
case: they carry empty `phase_urns` by design, because nobody searches
"primary schools in SW11", so they report no phase links on their own.

`phases` is a list rather than a single url. An all-through school is
listed on both the primary and secondary pages, so there is no tie to
break and no reason to invent one. Each entry renders directly after
its own place, so "22 primary schools in Brentwood" reads as part of
Brentwood rather than as an unrelated link further along the row.

The e2e journey now follows a phase link where the town publishes one
and asserts it resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 20:54:59 +01:00
TudorandClaude Opus 5 7f5f0fb676 feat(seo): link school pages into the location layer
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m24s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
W2 shipped ~5,000 place pages and nothing linked into them. The
location layer pointed down at school pages; school pages pointed
nowhere on the site. Their only anchor was the school's own website, so
the ~27k pages carrying most of the site's inbound authority passed it
straight off-site, and the new corpus was reachable mainly through the
sitemap.

Three things close the loop.

A reverse index over the place registry, places_for_urn, answers which
published places contain a school. Derived from the registry rather
than stored beside it, so the two cannot disagree about which places
exist: a place below the publish threshold is absent from the registry
and therefore never offered as a link. A test asserts that invariant
across every place in a built registry.

GET /api/schools/{urn} gains a `places` array carrying the name, count
and canonical path for each. It rides on the request the page already
makes, so the school page costs no extra round trip. The frontend types
it optional and defaults it to empty, because the two images deploy
separately and a frontend ahead of the API must render without it.

The page gains a "More schools near here" module and a BreadcrumbList.
The module orders narrowest first, because a reader on a school page
wants its town before its county, while the API orders widest first for
the trail. Anchors state their destination's size — "37 schools in
Brentwood" — which is worth more to a reader and a crawler than "see
more". With no published places it renders nothing rather than an empty
heading.

The trail is rooted at the homepage, not /schools. There is no /schools
index page; the location layer lives only at /schools/[place],
/schools/authority/[la] and /schools/near/[outcode]. Rooting it at the
bare path would have opened every breadcrumb with a link to a 404.
Outcodes are omitted from the trail: "schools near CM15" is a real
query and a useful link, but nobody navigates Essex to CM15 to a
school, and a breadcrumb claiming that describes a hierarchy the site
does not have.

School pages also now declare the School type rather than
EducationalOrganization, the parent type that covers universities and
nurseries alike.

The e2e journey asserts the round trip in both directions, following a
place page's own first school so the pair is genuinely related rather
than hardcoded. A one-way link is what already existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 20:45:50 +01:00
tudor 47f3591ed8 Merge pull request 'feat(flags): put /about and /blog behind flags, dark by default' (#144) from feat/about-blog-flags into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m22s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m10s
Reviewed-on: #144
2026-09-08 16:47:18 +00:00
TudorandClaude Opus 5 6fc7fce948 feat(flags): put /about and /blog behind flags, dark by default
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m39s
Both features ship dark. Neither is reachable in an environment where
its flag is off, and every flag in this system starts off, so a deploy
of this commit makes both disappear until someone turns them on
deliberately.

Two independent flags rather than one, which makes blog-on-about-off a
reachable state. That state is the whole reason the change is larger
than four notFound() calls: the blog leans on the About page for its
author identity. The Person entity is anchored at /about#tudor, and
that URL 404s while about_page is dark, so a post published in that
state would claim an author resolving to nothing. Worse than having no
named author. Both bylines fall back to unlinked text and the
BlogPosting attributes to the publisher instead, so every combination
of the two flags renders something correct.

Gated: /about, /blog, /blog/[slug], the RSS feed, both footer links,
and the matching content-sitemap entries. A sitemap must never
advertise a URL that 404s. With both dark it emits a valid empty
urlset rather than a 404, because robots.txt names it unconditionally.

Not gated: /admin. Posts have to be writable before the blog is worth
switching on, so flagging the panel would make the flag unflippable.

getFlags takes a revalidate rather than always using the 300s
constant. Reading a flag pins the calling route to the lowest
revalidate among its fetches, and the footer links live in the root
layout, so a naive gate there would have dropped every school and
place page from a weekly cache to a 5-minute one. The layout passes
604800, the floor those routes already declare, and the build confirms
all four SSG route families still prerender. The cost is one-way
latency: pages follow a flip in minutes, footer links within a week.

The e2e journeys follow the existing paired shape from the
admission_distance flag: a lit journey and a dark one for each flag,
reading state from whether /about and /blog respond rather than from
/api/flags, which another journey asserts is not publicly reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-08 17:12:25 +01:00
tudor eb6d918650 Merge pull request 'fix(cms): regenerate the import map so the Content field renders' (#143) from fix/payload-import-map into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m13s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m9s
Reviewed-on: #143
2026-09-02 20:12:17 +00:00
TudorandClaude Opus 5 d47ac71c47 fix(cms): regenerate the import map so the Content field renders
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m10s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
Creating a post in the admin panel showed no Content editor, and saving
failed validation on the field the writer was never shown.

The admin panel does not import field components. The server hands the
client a path per field, and resolves it through the generated map at
app/(payload)/admin/importMap.js. A richText field's path is
@payloadcms/richtext-lexical/rsc#RscEntryLexicalField. The committed map
held one entry, @payloadcms/next/rsc#CollectionCards, generated before
the blog collections existed and never re-run. A path missing from the
map is not an error the panel reports: the field simply does not render,
while required is still enforced server-side on save.

next build does not regenerate the map, so the stale copy shipped in the
image and the editor was equally broken on staging and production.

Regenerated with payload generate:importmap, which adds the lexical RSC
field, cell and diff components, BlocksFeatureClient for the Callout
block, and the default toolbar features.

Two things stop it drifting again. There was no script to run, so
package.json gets generate:importmap. And a test asserts the map carries
an entry for each thing the config asks for, in the source-reading style
of the other payload suites; against the old map all five fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-02 20:44:05 +01:00
tudor 17e5371e9c Merge pull request 'fix(cms): ship the initial Payload migration (recovers two commits stranded after #140 merged)' (#141) from fix/payload-initial-migration into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m13s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m13s
Reviewed-on: #141
2026-09-02 17:52:17 +00:00
TudorandClaude Opus 5 e2c63a9905 fix(cms): ship the initial migration so a container finds its tables
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m14s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m33s
Staging failed on boot with 42P01, relation "payload.users" does not
exist. The schema was empty because no migration existed, and the
adapter cannot create tables itself: db-postgres/connect.js gates push
on NODE_ENV !== 'production', so it is inert in a deployed container
regardless of config.

The generated migration is schema-qualified to "payload" throughout but
does not create that schema — schemaName says where tables go, it does
not create anything. It only worked against the throwaway database used
to generate it because the schema was created there by hand, so every
real environment would have failed on the first statement. CREATE SCHEMA
IF NOT EXISTS is hand-added at the top of up(), which makes it exactly
the kind of edit a regeneration discards silently; a test asserts it is
present and ordered before the first CREATE TABLE.

payload-types.ts is now committed rather than ignored. Ignoring it meant
CI typechecked against looser types than a developer with a generated
copy, which is how a Record<string, unknown> cast passed CI and then
failed locally the moment the file appeared. The post page uses the
generated Post and Media types instead, and narrows heroImage rather
than asserting it, since the field is an id at shallow depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 18:33:27 +01:00
TudorandClaude Opus 5 3f3c5953f6 style(copy): remove em dashes from the site's prose
The em dash is one of the clearest tells of machine-written text, which
is the exact impression this work exists to remove. Rewritten rather
than substituted: where a dash was carrying a real aside the sentence is
split or recast, not patched with a comma.

Covers the About page, the two Callout labels an editor sees in the
admin panel, and PUBLISHING.md, which defines the house style and should
follow it. The rule is now recorded in that house style and in the
spec's voice rules, so it survives this branch.

Code comments are left alone: they are not copy, and the surrounding
codebase uses the same punctuation throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 18:33:27 +01:00
tudor 124c6702a9 Merge pull request 'feat: a named author, an About page and a Payload blog' (#140) from feat/about-and-blog into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 45s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m32s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m8s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m36s
Reviewed-on: #140
2026-09-02 16:04:58 +00:00
TudorandClaude Opus 5 e25722d9ab fix(blog): hide drafts at the access layer, and back the --drop claim
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m9s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m26s
Review findings on #140.

Drafts were reachable. Posts granted unconditional public read and the
_status filter lived only in the pages that query the collection — which
is a convenience, not a control. Payload's documentation is explicit:
"The `draft` argument alone does not restrict documents with _status:
'draft' from being returned by the API." A direct GET /cms-api/posts
would have handed every unpublished draft to any visitor. Read access
now returns a query constraint for anonymous callers, which is the
documented mechanism.

The --drop claim was asserted across four files while the spec still
listed it as an open question. Now verified rather than assumed:
run_full_migration drops exactly ["school_results", "schools"] by name,
there is no drop_all() or DROP SCHEMA anywhere in backend/, the only
other drop is schema-qualified to marts, and nothing sets search_path.
The guarantee is stronger than schema isolation alone — those two table
names do not exist in Payload — so the claim stands, but it now rests on
cited code. The spec records the evidence and closes the open item.

findPost is wrapped in React's cache(): Next calls generateMetadata and
the page separately for one request, so every post view ran the same
query against Postgres twice.

The bare .lede rule was dead — .prose p scores (0,1,1) and outranks it —
so only .prose .lede ever applied. Removed, with the specificity noted
so the surviving selector is not "simplified" back into a silent
regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:48:59 +01:00
TudorandClaude Opus 5 07d586d0ad docs(blog): how to publish, and why the app has two route groups
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 36s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m11s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m18s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m43s
PUBLISHING.md carries the house style with the posts, so the standard
survives without the design doc to hand — including the rule that a post
states what a metric does not show, which is the strongest signal a
human wrote it.

CLAUDE.md gains the two constraints that are invisible from the code and
expensive to rediscover: metadata file conventions break if moved into a
route group, and the build must keep succeeding with DATABASE_URL unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:30:09 +01:00
TudorandClaude Opus 5 b793640507 feat(blog): add the blog index, post pages, RSS and content sitemap
The rendering split is dictated by CI building with no database.

/blog, /blog/rss.xml and /content-sitemap.xml have no dynamic params, so
Next prerenders them at build time and the build fails on a missing
Payload secret — caught here, not on staging. They are force-dynamic
instead: one indexed query against Postgres on the same Docker network,
and a newly published post appears immediately rather than waiting on a
revalidation. /blog/[slug] keeps ISR, because with no
generateStaticParams there is nothing to prerender; it is generated on
first request and cached, which is exactly what the collection's
afterChange hook exists to invalidate.

RichText takes `converters`, not `blocks`, in Payload 3.88, and the
default converters must be spread or every paragraph and heading loses
its renderer and the body comes out empty.

BlogPosting references the Person and Organization by @id rather than
repeating them, so every post and the About page resolve to one author
entity instead of declaring several people with the same name.

/sitemap.xml is proxied from FastAPI, which knows nothing about Payload,
so the Next-owned URLs get their own sitemap and robots.txt lists both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:28:58 +01:00
TudorandClaude Opus 5 21a5d18f59 feat(blog): add the posts and media collections
Drafts are on so a post can be written across sittings without saving
being publishing.

afterChange and afterDelete revalidate every path a post appears on.
Blog pages are ISR because CI builds with no database, so without these
a published post would not appear until the revalidate window expired —
up to an hour of a writer concluding that publishing is broken. Payload
runs in the same process as Next, so these are direct revalidatePath
calls with no webhook and no shared secret.

Media writes to an absolute /app/media matching the compose mount; a
mismatch would write into the container filesystem, where the next
redeploy silently discards it. Alt text is required rather than
optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:24:15 +01:00
TudorandClaude Opus 5 f614414070 feat(about): give the site a named author
The site had no author, no statement of why it exists and nobody
accountable for its numbers, which is most of why it reads as machine
generated.

The page states plainly that its author is not an education expert. The
credibility claim is lived experience — a parent going through primary
admissions — plus stated provenance for every figure, which is true and
cannot be undermined by someone noticing there is no teaching
qualification behind it. First name only: the Person JSON-LD carries no
familyName, worksFor or affiliation, and a test asserts it stays that
way.

The footer gains a fourth column, with a tablet breakpoint so four
columns pair up rather than crushing before the 768px collapse. The nav
is deliberately untouched — its mobile tab bar already carries four
items.

public/brand/tudor.jpg is NOT in this commit. The page references it and
will show a broken image until the photograph is supplied; a stock
portrait would defeat the entire point of the work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:22:52 +01:00
TudorandClaude Opus 5 310b63b0cb build(cms): wire Payload into the Docker image and both stacks
Uploads go to a named volume at /app/media. The directory is created in
the image before the mount and covered by the existing chown, because
Docker seeds a fresh named volume from the image path — a missing or
root-owned directory there fails every upload with EACCES at runtime,
long after the build passed.

PAYLOAD_SECRET uses the same :? form as AIRFLOW_ADMIN_PASSWORD: refuse
to start rather than boot with an empty secret and accept forged
sessions. Staging's must differ from production's, which the header
comment now says explicitly. Portainer prefixes volume names per stack,
so payload_media isolates itself.

prodMigrations is not wired yet — generating the initial migration needs
a reachable Postgres. Follows in its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:19:27 +01:00
TudorandClaude Opus 5 c2c76c5817 feat(cms): keep the admin panel out of the index
X-Robots-Tag rather than the robots.txt Disallow alone, for the same
reason the staging rule uses one: a Disallow blocks crawling, not
indexing, so a URL found from an external link can be indexed without
ever being fetched — and blocking the crawl means the noindex is never
seen. Both mechanisms are applied to /admin and /cms-api.

The existing CSP is frame-ancestors only, which restricts who may embed
the site rather than what a page may load, so it cannot break the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:17:55 +01:00
TudorandClaude Opus 5 c5a4d106da feat(cms): install Payload and serve the admin panel
Payload 3.88 runs inside the Next app against the existing Postgres, in
its own 'payload' schema so no pipeline operation on public — the app
tables, Airflow's metadata, migrate_csv_to_db.py --drop — can reach blog
content.

Its REST API is mounted at /cms-api. /api is the FastAPI proxy's
catch-all, which would swallow every admin call and forward it to the
backend with no error. The mount points live in lib/payloadRoutes.ts so
there is one definition and a test can assert it without importing
Payload: it is ESM-only, next/jest will not transform it, and appending
transformIgnorePatterns cannot un-ignore a package. Forcing it through
transpilePackages would change how the production build bundles Payload
to serve a test, so the live proof that /api still reaches FastAPI stays
where it belongs — the e2e journeys, which call /api/schools.

The package becomes ESM ("type": "module"), which Payload's CLI requires:
richtext-lexical has top-level await and the config cannot be require()d.
Only two files needed renaming, jest.config.cjs and a build script.

The build is verified to succeed with DATABASE_URL and PAYLOAD_SECRET
both unset, which is how CI builds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:17:01 +01:00
TudorandClaude Opus 5 2437ffce42 refactor(app): move site routes into a (frontend) route group
Payload's admin panel ships its own root layout rendering html/body.
Next allows multiple root layouts only when no app/layout.tsx exists, so
the site's routes move into their own group. Route groups are invisible
to routing: every public URL is unchanged, verified against the build's
route table.

The metadata file conventions deliberately stay at the app/ root. Moving
them into the group renamed /icon.png to /icon-4usi79.png (likewise
apple-icon and opengraph-image) and dropped /robots.txt altogether,
which would have broken the /icon.png cache-control rule, the
outputFileTracingIncludes entry for the share card, and robots.txt.

darkThemeSafety reads app/globals.css off disk rather than importing it,
so it needed its own path fix — a grep for import specifiers misses it,
and it fails as an unrunnable suite rather than a failed assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:11:48 +01:00
TudorandClaude Opus 5 eb648f3f76 build(next): convert the config to ESM so Payload can wrap it
withPayload() is ESM-only, so next.config.js has to become .mjs. That
file also carries the rule that keeps staging out of Google's index, so
the conversion goes in on its own, behind a test that asserts the rule
survived — along with the standalone output, the opengraph-image font
tracing and the analytics frame-ancestors CSP.

Jest resolves the .mjs config without extra configuration, so
jest.config.js is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:08:58 +01:00
TudorandClaude Opus 5 74e5fffc10 docs(about-blog): implementation plan for the About page and Payload blog
Nine tasks, each ending in an independently testable deliverable.

Two structural findings that the spec did not anticipate, both recorded
in the plan. Payload's admin panel ships its own root layout rendering
html/body, and Next allows multiple root layouts only when no
app/layout.tsx exists — so every existing route moves into an
app/(frontend) route group first, on its own, with the full suite as the
gate. Route groups are invisible to routing, so no public URL changes.

The second finding corrects the spec: adding /cms-api to the FastAPI
proxy's exclusion list would be dead code, because that catch-all only
ever matches /api/*. The route remap alone is sufficient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 15:59:44 +01:00
TudorandClaude Opus 5 748ef32180 docs(about-blog): design for a named author, an About page and a Payload blog
The site reads as synthetic because nobody is accountable for the
numbers, no editorial judgement is visible, and the voice is
institutional third person. This designs the fix: a named author
(first name, photo, explicitly not an education expert), a coded
/about page, and a blog backed by Payload CMS running inside the
existing Next app.

Also fills a hole in the SEO programme, which has eight workstreams
and no E-E-A-T or authorship signal on a YMYL corpus.

Records two collisions found while designing, both of which fail
badly if missed: Payload's default /api route fights the existing
FastAPI catch-all proxy, and withPayload() is ESM-only so
next.config.js — which carries staging's noindex header — has to
become next.config.mjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT1Ls4GbgLDXoQX7NAuHGT
2026-09-02 14:43:20 +01:00
tudor b0c4ea8282 Merge pull request 'fix(destinations): the DAG died on a null in a primary key' (#139) from fix/destinations-national-grain into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m36s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m6s
Reviewed-on: #139
2026-08-31 20:43:26 +00:00
TudorandClaude Opus 5 e236669fde fix(destinations): school rows and the England reference are different grains
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 52s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m27s
The annual DAG died with a BrokenPipeError from Meltano's log writer, which
is several frames from the cause: target-postgres exited first and the tap
saw its stdout close.

The tap declared primary_keys = [urn, ...] while emitting urn=None for the
national rows, and target-postgres turns primary_keys into a NOT NULL
constraint. The first national row of the run failed the insert and took
the loader with it. Every other tap in this repo keys on non-null columns.

Carrying two grains in one stream was the actual mistake, so the fix is to
separate them rather than paper over the null: four streams now, with
ees_ks4/ks5_destinations_national carrying no urn column at all — a school
identifier that is null in every row is a grain mismatch, not a column.
The staging models split the same way and the national mart reads the new
pair instead of filtering `where urn is null`.

Verified against the live API: the school stream yields 135,240 rows over
4,508 schools with no duplicate keys, no null key columns and all 31,382
suppression sentinels intact; the national streams yield 30 and 33 rows
with no urn column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-31 21:36:43 +01:00
tudor fb5a0928bd Merge pull request 'fix(airflow): a fixed admin password from the environment, and a tap that missed #137' (#138) from fix/airflow-fixed-admin-password into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 55s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m36s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m14s
Reviewed-on: #138
2026-08-31 19:50:00 +00:00
TudorandClaude Opus 5 264edd2e3a fix(airflow): a login that survives a container restart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 51s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m58s
The simple auth manager generates a random password on first start and
writes it to a file, so every restart of the api-server invalidated the
last one and the password had to be dug out of the container logs again.

The stack now writes that file itself from AIRFLOW_ADMIN_PASSWORD before
exec'ing the api-server. Airflow generates nothing when the file already
exists, so the login is whatever the stack environment says it is.

Written with python rather than echo, so json.dumps escapes a password
containing quotes, backslashes or non-ASCII correctly — verified against
`p@ss "wo\rd' £5`, which round-trips intact.

An unset AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits.
Falling back to a generated password would silently undo the point of the
change, and a compose-level `:?` gives the same refusal a readable reason.
This does mean the variable MUST be set in Portainer before the next
deploy of either stack.

Not affected by the two Docker gotchas in the upstream docs: this image
has no USER directive so it runs as root, and the file is rewritten from
the environment on every start rather than persisted on a volume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-31 17:38:42 +01:00
TudorandClaude Opus 5 cd2cbe7be6 build(pipeline): install the destinations tap in the image
meltano install would resolve it from pip_url, but five of the six custom
taps are also installed explicitly and a new plugin failing to appear is
not something you want to debug from a deploy log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-31 17:37:46 +01:00
tudor 73182d0c0c Merge pull request 'feat(destinations): say what happened to a school's leavers, without republishing what DfE withheld' (#137) from feat/ks4-destinations into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m2s
Reviewed-on: #137
2026-08-30 20:49:14 +00:00
TudorandClaude Opus 5 cbe3a9a772 fix(destinations): the table said 'withheld' for a category that just doesn't apply
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The Share column keyed off `percentage === null`, which is true for
not_applicable as well as suppressed, so a destination that does not apply
to the school was labelled as one DfE withheld — while the Pupils column
in the same row rendered blank. Two columns, one row, disagreeing about
what the row was, and one of them making a claim about DfE that wasn't
true.

Both columns now derive from `status`, which is the distinction the mart,
the SQLAlchemy model and the serialiser all preserve deliberately:
published shows the figure, suppressed shows the withheld badge,
not_applicable shows an em-dash with a title saying so.

A published count with no published percentage now derives its share from
the cohort rather than falling through to a marker — both halves are
published, so nothing withheld is involved, and it is the same derivation
the bar widths already use.

Verified the new tests fail against the old logic before keeping them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-30 21:48:39 +01:00
TudorandClaude Opus 5 2e9b5c83c5 fix(destinations): the masking pass can no longer exit unsafely
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 7m23s
Review found _mask_for_disclosure could return with its invariant broken
and say nothing. add_companion only ever withheld a *published* cell, so a
group with one suppressed category and every other one not_applicable —
routine in special schools and AP, where few categories apply — left the
loop with the lone suppressed cell still solvable. Reproduced on a
nine-pupil cohort: one hidden cell, cohort served, residual intact.

A disclosure-control pass that fails silently is worse than none, because
everything downstream trusts it. The loop now runs until the invariant
holds and escalates when no companion exists: the pupil group is dropped
from the payload, and an empty block serialises as None so the section is
absent rather than an empty shell. disclosure_invariant_holds() is exported
so tests assert it directly instead of re-deriving it, and an exhaustive
test sweeps all 81 suppression patterns of a four-category group.

Also fixes a test that set up six measures and checked one: the loop was
`for measure in ["school_sixth_form"]`. It now checks every measure, and
against the real invariant — none hidden, or at least two, rather than
"at least two", which the five published measures would have failed.

No regression on real data: 262 mainstream secondaries, all-pupils bar
still drawable on 94%, zero invariant violations, one disadvantaged group
dropped by the new escalation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-30 21:35:03 +01:00
TudorandClaude Opus 5 102397fe69 fix(destinations): withhold at the API, not just in the chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
Code review found the disclosure the whole design was meant to prevent.
R1 was written as a rendering rule and implemented as one: canRenderBar
stopped the bar being drawn, but GET /api/schools/{urn} still carried the
cohort and every published category. cohort - sum(published) returned
Whitley Bay's withheld further-education figure exactly — 18 pupils — to
any caller, and the RSC payload put it in the browser too.

app.py already stated the principle for admission_distance: this endpoint
is public and unauthenticated, so a field left in the payload is a
published field. The same reasoning applies here and did not get applied.

_mask_for_disclosure now closes both identities before serialisation —
categories sum to the cohort, and disadvantaged + other = all — by adding
secondary suppression until every row and column hides none or at least
two. My first attempt picked the smallest published cell as the companion
and a new test caught it choosing a zero, which protects nothing: the
residual still resolved to 18. The companion must carry pupils.

DfE's own aggregates are no longer served. Nothing rendered them, and one
spanning a single suppressed component names it.

Cost, measured over 262 mainstream secondaries: the all-pupils bar
survives on 94% rather than 100%. Zero lone-suppressed groups remain.

The e2e helper now tells a missing feature apart from missing data: it
fails if the API serves no destinations key at all, and skips if the key
is served but the annual DAG has not populated the marts. Failing on the
second would redden the staging gate for unrelated commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 19:58:02 +01:00
Tudor 68a192e430 Merge remote-tracking branch 'origin/main' into feat/ks4-destinations
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m4s
# Conflicts:
#	nextjs-app/__tests__/components/darkThemeSafety.test.ts
2026-08-28 18:45:27 +01:00
TudorandClaude Opus 5 ccd5074c90 test(e2e): destination journeys, including the no-bar rule
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 1m10s
The helper throws rather than skipping when no school returns a
destinations block: a silent skip would let a real regression in the
sections ride along unnoticed, which is why the distance journeys were
changed the same way in 4f01fbd.

The disadvantaged journey computes the residual itself and asserts it
appears nowhere on the page — the one number the section must never state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:16:25 +01:00
TudorandClaude Opus 5 2b4cf20d75 feat(destinations): the post-16 section, replacing the placeholder
The 'Post-16 destination data coming soon' note is deleted rather than
reworded: for a school with no sixth form the truthful statement is that
the question does not apply, and a placeholder there implies something is
missing. hasSixthForm and .sixthFormNote go with it — nothing else used them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:15:21 +01:00
TudorandClaude Opus 5 cef2f77149 feat(destinations): the After Year 11 section
Question cards over one bar, with the cards acting as a lens on the bar
rather than a summary beside it — focusing a card dims everything it is
not made of, so the grouping we chose is inspectable rather than asserted.

The bar renders only when canRenderBar allows it. Where a category is
withheld the section says so and shows the table instead: the categories
sum to the cohort, so a bar drawn from the published segments leaves a
gap whose width is the withheld figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:14:13 +01:00
TudorandClaude Opus 5 68b6417149 feat(destinations): types and secondary section flags
Destinations are secondary-only, so the flags go on computeSecondaryFlags
rather than computeSchoolFlags. A phase counts as present only when some
pupil group carries categories — an empty block would otherwise open a nav
entry pointing at a section that never renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:11:37 +01:00
TudorandClaude Opus 5 c5719ef362 feat(destinations): serve destinations without closing the gaps
The serialiser carries status through and computes no totals of its own.
The only aggregates in the payload are ones DfE published itself; whether
showing one is safe depends on how many of its components are suppressed,
which the frontend decides.

The batch guard grows from six tables to eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:10:29 +01:00
TudorandClaude Opus 5 5e5b61987a feat(destinations): marts, with R3 masking applied at the boundary
Disadvantaged and other-pupils partition the whole and the all-pupils
figure is published, so publishing both halves recovers the suppressed
one. The mask is applied in the mart rather than the API so no consumer
added later can reach an unmasked combination.

The R1 test is a warn, not an error: DfE publishes the recoverable
combination and the mart's job is to carry it faithfully. Refusing to
close the gap is the API's job and the frontend's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:08:20 +01:00
TudorandClaude Opus 5 c564566432 feat(destinations): staging models that keep 'withheld' distinct from 'absent'
safe_numeric maps every EES sentinel to NULL, which is right for attainment
and wrong here: one of those states has to print 'withheld' and the other
has to print nothing. A status column carries the difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:07:17 +01:00
TudorandClaude Opus 5 9188626051 feat(destinations): a tap that preserves the suppression sentinel
EES writes 'c' where a figure is withheld and the categories sum to the
cohort, so counts and percentages are emitted as text with the sentinel
intact. safe_numeric must never be pointed at them.

School rows and the England reference need different establishment pins:
at national level selective schools, studios and UTCs are separate
populations rather than labels, so leaving establishment open multiplies
30 rows into 190. Two queries per period, each keeping its own level.

Verified against the live API for 2022/23: 135,240 school records over
4,508 schools, exactly 30 each, no duplicate keys, 31,382 sentinels kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 16:06:37 +01:00
TudorandClaude Opus 5 7ae9ecdc36 feat(destinations): colour tokens, with the absence hatched not coloured
Activity not captured includes independent schools and moving abroad, so a
red segment would be a factual error. The hatch doubles as the secondary
encoding that rescues the neutral/blue pair, which separates at only dE 7.6
as flat fills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 15:58:25 +01:00
TudorandClaude Opus 5 1980d79eee feat(destinations): the disclosure rules, as executable guards
The destination categories sum to the cohort and DfE publishes the cohort
total, so a lone suppressed cell is recoverable by subtraction. canAggregate,
canRenderPublishedAggregate and canRenderBar are what stop a consumer doing
that; toBarSegments throws rather than leaving a readable gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 15:57:46 +01:00
TudorandClaude Opus 5 9423f11567 docs(destinations): implementation plan, ten tasks
Ordered so the disclosure guards land first and everything downstream
consumes them: lib/destinations.ts, tokens, tap, staging, marts, API,
then the two sections and the journeys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 15:56:36 +01:00
TudorandClaude Opus 5 576013d627 docs(destinations): design for KS4 and post-16 destination measures
The published files suppress individual cells, not whole cohorts, and the
categories sum to the cohort — so on 22% of mainstream secondaries the
withheld figure can be recovered by subtraction. Three disclosure rules
fall out of that, and the rest of the design is downstream of them.

Verified against the EES API rather than assumed: both datasets carry
school-level rows keyed by URN, with the disadvantage split and every
destination category the display needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 15:28:09 +01:00
tudor 7c08138fe4 Merge pull request 'fix(map): the popup never took the dark theme' (#136) from fix/dark-mode-map-popup-contrast into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m41s
Reviewed-on: #136
2026-08-27 21:57:53 +00:00
TudorandClaude Opus 5 a7829d591a fix(map): the popup never took the dark theme
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
leaflet.css paints `background: white; color: #333` on the popup card and its
tip. LeafletMapInner binds themed content into it — the school name and the
headline figure are var(--text-primary) — so in dark mode #E9EEF0 landed on
#FFFFFF at 1.17:1. The two things the popup exists to say were the two least
readable things on the page.

Every other foreground in that popup failed too, from the same cause: the
muted phase line at 2.90:1, the vs-national delta at 1.94:1, the Ofsted badge
at 1.74:1. Moving the surface onto --bg-card fixes all of them at once —
13.52, 5.45, 8.14 and 9.11:1 respectively. In light mode --bg-card is #FFFFFF,
so the popup renders exactly as it did.

globals.css already pulls the rest of Leaflet's chrome onto the tokens, and
says why: "this matters most in dark mode, where Leaflet's white attribution
bar would otherwise sit on a near-black page." The popup was simply missed.

The View Details button needed its own fix. It pairs background:var(--status-
above) with a literal white label, which theming the card does not reach:
--status-above is #36743F in light but #7FCB8A in dark, taking the label from
5.63:1 to 1.94:1. --text-inverse is the token for ink on a saturated fill, and
the popup's own Ofsted badge already uses it.

darkThemeSafety already guards this defect class, but only inside .module.css.
Neither half of this one lives there — the surface is a third party's, the
text is inline in a TSX template — so it scanned clean throughout. Two rules
added for the layer it could not see. Fixing the grouped-selector blind spot
in its rules() helper was needed to write them: taking only a selector's last
line discarded every selector in a grouped rule but the final one, which makes
a safety guard fail open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 22:34:18 +01:00
tudor 1ed4470fc2 Merge pull request 'fix(admissions): flag-off pages must not speak for the council' (#135) from fix/distance-flag-off-absence-copy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m43s
Reviewed-on: #135
2026-08-27 20:30:25 +00:00
TudorandClaude Opus 5 7a16b1b52f fix(admissions): flag-off pages must not speak for the council
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m8s
The secondary admissions section words the absence of a cut-off distance:
"<LA> has not published a cut-off distance for this school." That sentence
is true when the authority publishes nothing. It is false when the
authority does publish and the admission_distance flag is simply off — and
off is the current state, so every secondary page with an EES admissions
row has been making a claim about a council on our behalf.

The backend already draws the distinction the copy needs. /api/schools/{urn}
omits the admission_distance key entirely while the flag is dark rather than
sending null, precisely so that "we are not publishing cut-offs" stays
distinguishable from "this school has no cut-off"; lib/types.ts says so in
as many words. The page then collapsed the two with `?? null` before the
section ever saw them.

So stop collapsing it: thread the raw field to SecondarySchoolSections and
word the absence only when the feature is on. Null still gets the sentence
naming the authority — that case is unchanged and still tested.

Primary pages are unaffected: AdmissionsSection carries no absence copy and
renders nothing when there is no figure. DistanceSection already treated
absent and null alike; only its type widens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 21:17:16 +01:00
tudor cf9d41b476 Merge pull request 'fix(analytics): the funnel source read a referrer that never changes' (#134) from fix/navigation-source-soft-nav into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m40s
Reviewed-on: #134
2026-08-27 08:26:45 +00:00
TudorandClaude Opus 5 e820e7fecd fix(analytics): the funnel source read a referrer that never changes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m13s
The staging E2E gate has been red since #132 merged (run 1064, and
1066 after it): "a school reached from a location page is attributed
to it, not to direct" expects `place`, receives `direct`.

#132 fixed a real bug — `/schools/` had no case and fell through to
`direct` — but the mechanism underneath it never worked.
getNavigationSource read document.referrer, which the browser writes
only when a *document* loads. Every internal navigation here is an App
Router soft navigation: history.pushState, no new document, so
document.referrer goes on naming whatever opened the tab for the whole
session.

Verified on staging: load /schools/brentwood, click a school, the URL
becomes /school/… and document.referrer is still "".

So `from` reported `direct` for essentially every in-app journey, not
just the ones through the location layer — search, rankings, compare
and detail were all being counted as "typed the URL". The unit suite
passed throughout because every case set document.referrer directly,
which only happens on a full page load.

The fix is a module-level trail written by RouteTrail, a render-nothing
client component in the root layout. Its lifetime is exactly right: it
survives soft navigation, and it dies on a real document load — which
is precisely when document.referrer becomes meaningful again, so the
two cover each other with no overlap.

Reading it skips entries equal to the current path rather than taking
the second-to-last. That makes the answer independent of whether the
layout effect or the page effect ran first — React orders those by
tree position, which is not a contract worth resting a measurement on
— and it gives the right answer both when the user returns to a page
they came from and on a hard load of a school page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 09:22:37 +01:00
tudor 4fdeb70a93 Merge pull request 'feat(places): say what each school is, not only how it scored' (#133) from feat/place-school-attributes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m42s
Reviewed-on: #133
2026-08-27 07:59:10 +00:00
TudorandClaude Opus 5 9a1f56c431 feat(places): say what each school is, not only how it scored
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m44s
The location tables carried one column: a percentage. A parent
shortlisting from a town page is asking a different question first —
does it take my child's age, is it a faith school, does it have a
nursery — and the page could not answer any of it.

Primary tables gain Ages, Religious character, Nursery and
Constituency; secondary tables the same minus Nursery, which is a
question about a different intake. An all-through school renders in
both groups, so its nursery shows under primary alone.

The measure moves to the second column rather than the last. Six
columns overflow a phone and .tableWrap turns that into a horizontal
swipe; with the measure last, the one number the page exists for is
the one scrolled off the screen.

Cell rules are the ones the school page already uses, so the two
surfaces cannot disagree about the same school: "Does not apply",
"None" and "Not applicable" all read as no religious character, and
the en-dash age normalisation moves into formatAgeSpan, which
formatAgeRange now delegates to.

Backend: nursery_provision and parliamentary_constituency were not in
the place response. Both are optional GIAS mart columns that
data_loader degrades to NULL, and the `in rows.columns` guard keeps a
mart the pipeline has not rebuilt working.

Also fixes a live bug on the same line: SCHOOL_COLUMNS already ends
with latitude and longitude, and the endpoint concatenated them again,
so pandas dropped one of every duplicated pair and warned "columns are
not unique" on each request. Ordered de-duplication removes the
warning and the silent drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 08:49:14 +01:00
tudor ade9dbb3ba Merge pull request 'feat(analytics): measure the location layer, and stop calling it direct' (#132) from feat/place-analytics into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m44s
2026-08-27 07:29:48 +00:00
TudorandClaude Opus 5 d1a8596208 feat(analytics): measure the location layer, and stop calling it direct
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 40s
The location pages were only half-tracked. Umami counts a pageview for
each of the ~3,900 URLs automatically, but nothing else: components/
places contained no track() call, and place_viewed was not even a
declared event name.

The part that mattered was worse than a gap. getNavigationSource mapped
a same-origin referrer to a funnel source and had no case for /schools/,
so every school view arriving through the location layer fell through to
'direct' — the bucket you read as "typed the URL, no referrer". W2's
whole purpose is funnelling search traffic onto school pages, so the one
measurement that says whether it worked was reporting the wrong answer,
and reporting it confidently. Verified live against staging: expected
"place", received "direct".

/schools/ is checked before /school/. They differ by one letter and mean
different things — the location layer versus a single school — and a
prefix test in the wrong order silently merges them.

place_viewed carries kind, slug, phase and school_count. kind is the
reason it exists: whether to keep investing in these pages turns on
which sort earns engagement, and a pageview cannot say, because all four
families share the /schools/ prefix and only the registry knows which is
which. It is a client component because PlaceView is a server component;
one line in PlaceView covers all four families, since they all render
through it.

Both E2E journeys were verified failing against staging first — one
because place_viewed does not exist there, the other on the exact
"place" vs "direct" mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-27 08:23:21 +01:00
tudor a3c09d9b67 Merge pull request 'fix(suggest): the dropdown reopened on top of the search results' (#131) from fix/suggest-reopens-over-results into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m37s
Reviewed-on: #131
2026-08-26 21:07:55 +00:00
tudor a7f4c86464 Merge pull request 'fix(search): the mobile hero search was indented by a card's padding' (#130) from fix/mobile-hero-search into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #130
2026-08-26 20:51:20 +00:00
TudorandClaude Opus 5 0804566736 fix(test): drop a committed scratch probe, and close a hole in the guard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Code review, all three findings valid.

e2e/tests/__m.spec.ts was a throwaway probe used to measure the mobile
hero geometry. It asserts nothing, so it could never fail; it carried a
leftover `pick('form').constructor === Object ? null : null` that is
null either way and throws if no form matches; and it should never have
been committed. Deleted.

It survived because `rm -f e2e/tests/__m.spec.ts` ran with the shell
already inside e2e/, so the path resolved to e2e/e2e/tests/... — which
does not exist, and rm -f is silent about that. `git add -A` then swept
it in. I checked `git diff --stat` before committing, which lists only
tracked modifications and never shows an untracked file; `git status
--short` would have.

The scoping guard compared the last line of a rule's prelude against the
literal '.filterBar', so a regression written as a selector list —
`.filterBar, .other { padding }`, or the same split across two lines —
would have walked straight past the test meant to catch it. Selectors
are now split on commas and matched individually, and comments are
stripped first so a brace inside one cannot desynchronise the parse.

Verified against all three shapes: bare, inline comma list, and
multi-line comma list. Each is caught; each passes again once reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:49:48 +01:00
TudorandClaude Opus 5 55363cbd18 fix(suggest): the dropdown reopened on top of the search results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 55s
Three staging-gate failures, two of them one real bug.

After a search, the results-page bar still holds the term in its input,
so on every render the query was >= 2 characters and the suggestion list
opened again — on top of the very results the search had just produced.
Playwright reported it as "<li role=option ...> intercepts pointer
events" while trying to click the first result; a reader would simply
have found their first result unclickable. Both the school-detail and
hero-map journeys failed on it, and neither is about autosuggest.

Suggestions now answer typing, not the mere presence of a value:
`hasTyped` gates the hook, is set on change, and is cleared when a
search is submitted or a suggestion is chosen. A pre-filled input makes
no request and shows no list.

Third failure was my test, not the product. An unphased place page
renders one table per phase, and an all-through school legitimately
appears in both — so the page's school links were never one alphabetical
run. The assertion collected them all together and only passed because
no town it picked had held an all-through school. When the data gave
Abbots Langley one, Breakspeare School appeared in the primary table and
again in the secondary, and the test failed on correct behaviour. It now
checks each table separately, and passes against the data that broke it.

Guards: a jest test that a pre-filled input neither fetches nor opens
(verified by reverting — it is the only one that fails), and an E2E
journey that submits a search and then requires the first result to be
clickable, which is the reader-facing version of the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:38:16 +01:00
tudor 868eb344f5 Merge pull request 'fix(map): the hero map's fade to the header was hardcoded white' (#129) from fix/dark-map-fade into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #129
2026-08-26 20:32:45 +00:00
TudorandClaude Opus 5 0b15497c09 fix(search): the mobile hero search was indented by a card's padding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m3s
Measured at 390px: the headline and lede sit at x=34, while the search
box, the hint and the location link all sat at x=48 and the field was
28px narrower than the copy above it.

The 14px came from `@media (max-width: 768px) { .filterBar { padding:
0.875rem } }`. That rule is for the results filter bar, which is a card
— background, border, shadow — and needs inner padding. The hero search
is not a card: .heroMode strips all of it, padding included.

Both selectors are specificity (0,1,0), so source order decides, and
.heroMode only wins because it is declared right after .filterBar. A
bare .filterBar rule inside a media query comes later and silently wins
instead. The two rules directly below this one in the same block were
already written as `.filterBar:not(.heroMode)`; this one was missed.

Scoping it aligns the search box, hint and location link to the same
left edge as the headline and gives the field back its 28px.

The location link also carried its own 6px of button padding, so its
label started further right than the hint even once the boxes agreed.
Pulled back with a negative margin, which keeps the tap target.

The guard is a stylesheet test: the failure is a plausible-looking
layout rather than a broken one, so nothing short of measuring or
looking would catch it. Verified by reverting: it names ".filterBar sets
padding".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:32:41 +01:00
tudor d55f6cce23 Merge pull request 'fix(suggest): let the dropdown out of the hero panel' (#128) from fix/hero-dropdown-clipping into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #128
2026-08-26 20:23:33 +00:00
TudorandClaude Opus 5 3236efa846 fix(map): the hero map's fade to the header was hardcoded white
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 49s
The fade between the map band and the school header ramped through
rgba(255,255,255,...) and landed on var(--bg-card). In the light theme
that is white into white and invisible, as designed. In the dark theme
it climbed to 95% WHITE and then met a near-black card, putting a bright
band across the full width exactly where the map should dissolve into
the title.

Fading to the colour the gradient lands on is the whole trick, and it
only works if that colour is a token — so --bg-card-rgb now exists in
both theme blocks, matching the --hero-ground-rgb precedent.

Two more defects in the same file, same cause, found while in there:

The controls floating over the map paired a hardcoded white background
with color: var(--text-primary), which resolves to #E9EEF0 in dark —
near-white text on a near-white button. These deliberately do NOT follow
the theme, because the map tiles are light in both, so the ink is now
literal too and says why. A themed token is the wrong tool for a surface
that never changes.

The loading skeleton swept 50% white across var(--bg-secondary), which
is a bright flash every 1.4s on a dark page. It now sweeps toward the
card colour, a shade lighter than the ground in both themes.

The guard is a stylesheet test rather than a render test, because the
bug is invisible in the theme it was written for. Verified by reverting
each fix in turn: it names .fade and .openHint exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:20:57 +01:00
TudorandClaude Opus 5 d5a6db289d fix(suggest): let the dropdown out of the hero panel
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
.heroPanel had overflow: hidden to clip its artwork and scrim to the
rounded corners. It clipped the suggestion dropdown too. Measured on
staging with the flag on: the list runs 482 to 802, the panel ends at
624 — so 178px of 320 was cut off, about half the options, with nothing
on screen to say anything was missing.

The two things that actually needed clipping now round themselves:
.heroArt gets border-radius: inherit plus its own overflow, and the
::before scrim inherits the radius. Below 860px the artwork is a band
flush with the top of the panel rather than a layer covering it, so it
takes the top two corners only — inheriting all four would leave it
floating with rounded corners against the copy.

Nothing else depended on the panel clipping: .valueProps below it is
entirely static, so a positioned dropdown paints above it without a
z-index fight.

The regression test asserts the LAST option is the element actually
painted at its own coordinates. toBeVisible() would not have caught
this — it checks for a non-empty box and visibility, and an ancestor's
overflow clips neither. elementFromPoint catches clipping and occlusion
alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:10:32 +01:00
tudor d8ccb5b733 Merge pull request 'feat(suggest): school autosuggest, and the rate-limit fix it needed first' (#127) from feat/school-autosuggest into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #127
2026-08-26 19:58:57 +00:00
TudorandClaude Opus 5 0fa1a292c7 fix(api): bound what a forged CF-Connecting-IP can buy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Code review, both findings valid.

The design doc claimed Cloudflare "replaces the header, so a browser
cannot forge it", and that only the X-Forwarded-For fallback was
forgeable. That is true only for traffic that actually passed through
Cloudflare, and nothing in this process can verify that it did. Reaching
the origin directly, both headers are equally attacker-controlled — and
rotating CF-Connecting-IP mints a fresh rate-limit bucket per request,
defeating per-client limits on every endpoint including the
DataFrame-heavy /api/schools. Against abuse that is worse than the
shared bucket it replaced, which at least capped everyone together.

So the ceiling comes back. I dropped it earlier arguing it belonged at
Cloudflare; that argument assumed the keying was sound, and it is not.
GlobalRateLimitMiddleware counts all /api/ traffic in a fixed window
against a total, independent of client identity, outermost so it refuses
before any work happens. Written by hand because slowapi cannot express
a global cap: default_limits and application_limits are both keyed by
key_func, and the latter needs middleware this app does not install.

It does not make the header trustworthy — it makes trusting it
survivable. The real fix is Authenticated Origin Pulls or an origin
firewall, now documented in DEPLOY.md as the open gap it is.

127.0.0.1 is exempt: the healthcheck curls localhost from inside the
container, and starving it would restart the container and turn a load
spike into an outage loop. Keyed on the peer address, never the Host
header, which the caller sets.

Second finding: suggest_schools_typesense promised "never raises" while
the parsing loop sat outside the try, so int(None) on a malformed
document would have made a keystroke a 500. The loop now skips bad rows
rather than dropping the whole list — and a hit with no document no
longer becomes a suggestion pointing at /school/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:56:32 +01:00
tudor c3f044bd65 Merge pull request 'docs(flags): Unleash does not create flags by itself' (#126) from fix/flags-runbook into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m46s
Reviewed-on: #126
2026-08-26 19:48:58 +00:00
TudorandClaude Opus 5 d2115364ae test(e2e): autosuggest journeys, gated on the flag
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m13s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m40s
Feature state is read from its observable effect — whether the search
box is a combobox — because /api/flags is denied to the public on
purpose. Same approach as the distance journeys.

The three endpoint tests are ungated: /api/suggest is live whether or
not the UI is, which is what lets it be smoke-tested in an environment
where the feature is still dark.

The flag-off journey asserts the plain search still works, not just that
the combobox is absent. Verified against staging, where the flag is off:
it passes and the flag-on journey correctly skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:39:33 +01:00
TudorandClaude Opus 5 28cf0a342c feat(suggest): wire autosuggest into the search box behind a flag
Off means off — no combobox role, no listener, no fetch. A test asserts
the absence of the request, not just the absence of the dropdown,
because a hidden-but-fetching control would still be spending the rate
limit on a feature nobody can see.

Enter with no active option falls through to the form's submit handler
and searches the typed text exactly as before. The existing behaviour is
preserved, not replaced, and that has its own test.

Suppressed once the value parses as a postcode: the box takes a name OR
a postcode, and suggesting schools during postcode entry fights the user.

.omniBoxContainer gains position: relative — the dropdown is absolutely
positioned and without it would have anchored to the page instead.

Four render sites, all wired: page.tsx renders HomeView in the success
path AND the catch fallback, and HomeView renders FilterBar as hero AND
sticky. Missing any one would make the flag silently do nothing
somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:38:52 +01:00
TudorandClaude Opus 5 d88e77f459 feat(suggest): the dropdown, with combobox ARIA
Presentational only — it fetches nothing and owns no state, so the
fetching rules and the ARIA rules can be read separately.

onMouseDown, not onClick. The input's blur handler closes the list and
blur fires before click, so a click handler never runs: the classic bug
where a dropdown works perfectly by keyboard and is dead to the mouse.

The plan's CSS guessed at token names like --color-surface. The real
tokens are --bg-card, --border, --text-muted, --bg-secondary and
--shadow-soft, and all five are redefined in the dark theme — invented
names would have silently fallen back to hardcoded light values and
broken dark mode.

Local authority is rendered because there are many schools called
'St Mary's'; a list without it is unusable for exactly the query
autosuggest exists to serve, which is what the test asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:36:39 +01:00
TudorandClaude Opus 5 06eb433db5 feat(suggest): debounced, abortable suggestion hook
The AbortController is correctness, not economy. Without it a slow
response for 'st' can land after the fast one for 'st marys' and replace
a correct list with a stale one — the classic autosuggest race.

No cache: 'no-store', unlike the compare modal's search. This is the one
endpoint where prefix queries repeat most across users, so discarding
the browser cache and the backend's ETag 304s would be throwing away the
cheapest win available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:35:27 +01:00
TudorandClaude Opus 5 1a6d349dad feat(suggest): GET /api/suggest, cacheable and DataFrame-free
A dedicated endpoint rather than a mode of /api/schools, because that
path filters and sorts 25,000 pandas rows per query while holding the
GIL — affordable once per search, not once per keystroke. A test asserts
the distinction directly by making load_school_data raise and requiring
the endpoint to answer anyway.

Nothing errors on ordinary input: a short query, no matches, or
Typesense being down are all 200 with an empty list.

Cached deliberately. Prefix queries repeat enormously across users and
school names change once a year, so s-maxage plus the existing ETag
middleware turns most keystrokes into 304s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:34:34 +01:00
TudorandClaude Opus 5 75d3534d82 feat(suggest): Typesense rows for autosuggest, no DataFrame
search_schools_typesense returns URNs, which forces the caller to
hydrate from the 25,000-row in-memory frame. Every field a suggestion
needs is already in the Typesense document, so this returns documents
and the caller needs no pandas at all — the difference between a query
that can run per keystroke and one that cannot.

Never raises. Typesense unreachable or erroring gives an empty list,
because a dropdown that quietly stops appearing is the right failure for
a keystroke path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:33:42 +01:00
TudorandClaude Opus 5 ff041544f2 fix(api): rate-limit per caller, not per proxy
The limiter keyed on request.client.host, which in staging and prod is
the Next container — the backend has no published ports and nothing else
can reach it. So every browser user on the site shared one 60/minute
bucket per route. Measured against staging: 70 concurrent requests to
/api/schools returned exactly 60 OK and 10 refused, from one machine.

CF-Connecting-IP first. Cloudflare fronts both environments and
overwrites any client-supplied value, which a parsed X-Forwarded-For
chain does not guarantee. The XFF fallback is forgeable only from inside
the Docker network.

Named rather than hidden: the shared bucket was an accidental global
throttle on a single-process backend, and correct per-user keying
removes it. A real global ceiling belongs at Cloudflare, which is
already in the path; slowapi cannot express one without a second Limiter
and middleware this app does not install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:32:58 +01:00
TudorandClaude Opus 5 59265f78b6 docs(flags): Unleash does not create flags by itself
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 35s
The runbook said a flag 'appears in the Unleash UI after the backend has
evaluated it once'. That is wrong. SDKs read definitions from the server
and never register anything, and metrics for an unknown flag are
discarded — so a declared flag is evaluated on every request, stays
False forever, and never shows up until someone creates it by hand.

Found the way these things usually are: staging had been running the
flag code for a while and the UI was still empty.

Also names the environment trap while here — each stack's token is
scoped to one environment, so toggling the other does nothing visible
and looks like the flag is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:11:45 +01:00
TudorandClaude Opus 5 6e0a278340 docs(suggest): implementation plan, eight tasks
Self-review caught three defects in the plan. .omniBoxContainer, the
wrapper the dropdown positions against, does not declare position:
relative — without it the list anchors to the page. The postcode
suppression test typed character by character, so it would have asserted
no request while 'NW1' legitimately fires one; it now sets the value in
one go. And the Enter-submits-search assertion needed waitFor, because
updateURL pushes inside startTransition.

Task 1 is the one to review hardest: it is the only unflagged change and
it alters rate limiting for every endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:48:39 +01:00
TudorandClaude Opus 5 e651dd0d65 docs(suggest): the in-app global ceiling would not have worked
Reading slowapi rather than assuming: default_limits and
application_limits are both evaluated with the same key_func, so they
are per-client across routes, not global. And application_limits only
apply 'if in_middleware' — this app installs no SlowAPIMiddleware, so
they would never have fired at all.

A genuine global cap would need a second Limiter with a constant key
plus that middleware. Cloudflare is already in the path on both
environments and does this at the right layer, so the ceiling is named
as a follow-up there rather than built badly here.

The risk that leaves is stated plainly in the risks section instead of
being papered over with a mechanism that does not do the job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:44:21 +01:00
TudorandClaude Opus 5 22c113fc29 docs(suggest): design for school autosuggest
The load-bearing finding is not about autosuggest. The rate limiter keys
on request.client.host, which in staging and prod is the Next container
— so all browser users share one 60/min bucket per route. Measured
against staging: 70 concurrent requests gave exactly 60 x 200 and
10 x 429. Eight concurrent searchers would 429 the site once each
keystroke costs a request, so the keying fix is part of this work.

Both environments are behind Cloudflare, which sets CF-Connecting-IP and
overwrites any client-supplied value — trustworthy in a way a parsed
X-Forwarded-For chain is not, and the backend is unreachable except
through the Next proxy.

Named honestly: the shared bucket has been an accidental global throttle
on a single-process backend, so correct per-user keying removes a
protection. A global ceiling ships with it rather than instead of it.

Suggestions come from Typesense alone. The existing search path filters
a 25,000-row DataFrame per query, which is exactly the cost a keystroke
endpoint cannot pay, so there is deliberately no DataFrame fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:41:00 +01:00
tudor e953ee7c5f Merge pull request 'feat(flags): ship-dark feature flags, with last-distance-offered behind the first one' (#125) from feat/feature-flags into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 40s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m34s
Reviewed-on: #125
2026-08-23 11:34:56 +00:00
TudorandClaude Opus 5 413d86cc3c chore(flags): wire UNLEASH_URL and the SDK cache volume into the stacks
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 27s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m59s
Both variables default to empty, so an environment without Unleash has
every flag off — the correct dark state rather than a boot failure.

The cache volume is the mitigation for the one real regression risk in
this design: the SDK evaluates everything False until it syncs, so a
backend cold-starting with an empty cache while Unleash is unreachable
would make a *released* feature disappear. On a named volume the disk
cache survives a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:58:29 +01:00
TudorandClaude Opus 5 4f01fbdedb test(e2e): make the distance journeys fail loudly, not skip quietly
The existing distance journeys all skip when no school has a published
figure, which is right when the feature is off — and wrong when it is
supposed to be on and is silently broken, because that shows up as a
green run full of skips. The new gate fails in exactly that case.

Feature state is read from the data, not from /api/flags: the public
proxy denies that path on purpose, since it names unreleased features.
Presence of the admission_distance key is the observable effect.

Verified against staging, where the feature is currently on: the on-gate
passes, the off-gate skips, the existing eight distance journeys are
unaffected.

One honest caveat — the /api/flags check passes on staging today because
that image predates the endpoint, not because the denylist works. The
denylist itself is covered by the jest unit test; this is defence in
depth and becomes a real assertion once deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:57:34 +01:00
TudorandClaude Opus 5 c3ba7aae0d feat(flags): ship last-distance-offered dark behind a flag
One gate, at the source. The frontend needs no change: DistanceSection
already returns null when distance_m is missing, and the admissions
block already conditions on (admissions || admissionDistance). Only 57
local authorities publish cut-offs, so the off-path is the commonest
path on the site and is well covered already.

Absent, not null. /api/schools/ is public and unauthenticated, so a
field left in the payload is a published field — the reasoning already
recorded in c9a1892 when history was withheld. The two are also
different claims: null says this school has no cut-off, absent says
cut-offs are not being published at all. The frontend type now says so.

The feature is on main and live on staging and has never reached
production, which is what makes it the right first consumer: the flag
lets the code promote without the feature appearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:56:33 +01:00
TudorandClaude Opus 5 54a30de0d8 feat(flags): server-side getFlags for the frontend
Ships without a consumer, deliberately. The first flag needs none — the
backend withholds the field and the page follows — but 'UI elements on
existing pages' is one of the three surfaces this capability exists for,
and a flag layer that cannot gate one is incomplete.

Never throws: an unreadable flag is a dark one, which matches the
backend's fail-closed default. A page that 500s because the flags
endpoint blinked would be a worse outcome than a hidden feature.

Reading flags pins the calling route to a 300s ISR floor, since Next
takes the lowest revalidate among a route's fetches. That matches what
/school/[slug] already sits at, and it is the same property that makes a
flip propagate without a webhook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:55:32 +01:00
TudorandClaude Opus 5 c30ad1db07 feat(flags): serve /api/flags, and keep the public proxy off it
The endpoint and its exposure control ship together on purpose. The
moment /api/flags exists, app/api/[...path] forwards it — and the
response names every unreleased feature the codebase knows about, along
with whether it is on. Publishing that is the opposite of shipping dark.

Denied on an exact first-segment match, not a prefix, so /api/flagship
does not go down with /api/flags. Next reads the endpoint server-side
over the Docker network, which never transits the public proxy.

jest.setup.js now guards its browser globals. It runs for every suite,
including the one that declares @jest-environment node to exercise the
route handler — NextRequest needs Fetch API globals jsdom lacks, and
there is no window there to define matchMedia on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:54:26 +01:00
TudorandClaude Opus 5 7424cef7c6 feat(flags): the registry and a fail-closed Unleash client
Unleash holds flag state; it does not hold the list of flags. REGISTRY
is that list, because the SDK evaluates an unknown flag to False and
without a registry that is an undeclared False — indistinguishable from
a typo in a flag name.

Fail-closed throughout, and never raises: an unset UNLEASH_URL, an
unreachable server, a client that throws, an undeclared name — all
False. A flag layer that can 500 a request path or stop the API booting
is worse than one that is switched off.

Every flag defaults to False, with no per-flag override, because a flag
that defaults on is a kill switch and this is deliberately not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:52:55 +01:00
TudorandClaude Opus 5 01ccbb8e82 feat(flags): add the Unleash stack and its runbook
Its own Portainer stack, belonging to neither application stack: a
staging redeploy must not be able to disturb production's flag state.

One instance serves both. OSS Unleash ships development and production
environments with environment-scoped client tokens, so the same flag
holds independent state in each — which is what lets a feature be on in
staging, where the E2E journeys exercise it, while production stays dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:51:35 +01:00
TudorandClaude Opus 5 c339c2f1a1 docs(flags): implementation plan, eight tasks
Task 1 is the Unleash stack and ends with a human step — the Portainer
deploy and the token generation cannot be automated from here. Nothing
else blocks on it: an unset UNLEASH_URL means every flag is False, which
is what local development and CI get, so the whole suite runs without a
flag server existing.

Self-review caught three defects in the plan itself. get_supplementary_data
takes (db, urn), not (urn), and the test DataFrame was minimised to the
point where the endpoint would have failed for reasons unrelated to
flags — both now copy the known-good shape from test_school_details.py.
The proxy test needs the node jest environment, since NextRequest wants
Fetch API globals jsdom does not provide. And the e2e off-state check
hardcoded a URN, so a 404 page would have satisfied it without proving
anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:46:15 +01:00
TudorandClaude Opus 5 e2ca3d79f9 docs(flags): drop the webhook — the seven-day premise was wrong
Next uses the LOWEST revalidate among a route's fetches, not the segment
value. School pages fetch school details at 300s and place pages fetch
national averages at 3600s, so the effective ISR period is five minutes
and one hour respectively — not the seven days the segment declares.

A flag flip therefore propagates on its own, well inside the monthly,
by-hand cadence these flags are for. That deletes two webhook
integrations, a revalidate route, a secret-in-query-string scheme, an
idempotency requirement, and the rule that every fetch carry a cache
tag — which was the part most likely to rot as fetches are added.

Two constraints survive: a flag must never gate content on a
force-static page, because app/admissions never revalidates; and a
route-family flag must rebuild the sitemap, deferred with the route case
since no flag in scope touches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:40:35 +01:00
TudorandClaude Opus 5 c2364bf09e docs(flags): design for a ship-dark feature flag layer
Unleash self-hosted in its own Portainer stack, with FastAPI holding the
only SDK and Next reading flags through a tagged fetch.

The two hard parts are consequences of putting flag state in a service
rather than the repo: main stops being the whole truth about what is on,
and a flag can now change without the deploy that would have cleared the
caches. A code-declared registry bounds the first; webhook-driven
revalidateTag handles the second.

Cache tagging is deliberately coarse — every server fetch carries the
flags tag, not just the flags fetch itself. The first consumer proves
why: admission_distance changes the shape of /api/schools/{urn}, so a
narrow purge would leave ~25,000 school pages serving the pre-flip
render for a week, invisibly.

First consumer is the last-distance-offered feature, which is on main
and staging and has never reached production. It needs one gate, at the
API, because the frontend already no-ops on a missing field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 09:47:58 +01:00
tudor 43e0621728 Merge pull request 'fix(places): phase links must stay in their own namespace' (#124) from fix/place-phase-links into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m31s
Reviewed-on: #124
2026-08-22 17:33:12 +00:00
TudorandClaude Opus 5 d1358cc00f fix(places): phase links must stay in their own namespace
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the
shape that belongs to towns alone.

On an authority page that pointed into the town namespace. For 87 of the
151 authorities the target does not exist and the link 404s; for the
other 64 it resolves to the town of the same name — a different set of
schools, which is precisely the near-duplicate the two namespaces were
introduced to prevent. On an outcode page it 404s outright.

Two causes behind it, both a rule written twice and inherited by only
one of the places that needed it.

The authority phase route was in the spec and dropped by the plan, which
built the three bare routes and no fourth. The sitemap is generated from
the place registry, which was right about them all along, so 302
authority phase URLs have been submitted to Google and every one 404s.
Adding the route makes the sitemap true and serves a real query —
admissions are authority-run, so "primary schools in Kent" is how a
parent searches before they have settled on a town.

The outcode variants were the opposite: the registry computed phases for
outcodes although the spec gives them no route, and the sitemap knew to
skip them while the API did not. The registry now decides alone, and the
sitemap's duplicate of that rule is gone.

Also: an authority under the five-school threshold has no page, so the
API sends a null slug for it and the page names it without linking.
Two English authorities are in that position. It was unreachable in
today's data — verified across the EC and TR outcodes — but the thin
place redirect would have sent a reader to a 404 the year it isn't.

The e2e journey now walks every /schools link a page of each family
emits and requires a 200, which is the check that was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 17:22:11 +01:00
tudor 865a69b54d Merge pull request 'feat(places): list schools alphabetically on place pages' (#123) from feat/place-alphabetical-sort into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m27s
Reviewed-on: #123
2026-08-21 23:22:49 +00:00
TudorandClaude Opus 5 9cc87c41bb fix(places): a phase page needs results, not merely publishable schools
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m4s
Asked where schools with no results should sit in an alphabetical list, and
found that some pages were almost entirely made of them.

The per-phase threshold counted schools that were publishable — a result OR
an Ofsted grade — while a phase page exists for its results column.
/schools/kent/primary published with none of its five rows carrying a result;
Minehead had one of seven, Buntingford one of five. Forty-four phase pages
were majority-blank.

It is the same rule as "no page without a local average", which was written
into the spec as a thin-page control and never extended per phase.

The threshold now counts schools with a result for that phase. It gates
whether the page exists; it does not filter rows — a page that publishes still
lists every school of the phase, because someone looking up a school by name
has to find it whether or not it published results.

126 of 1,012 variant pages stop publishing: 62 primary, 64 secondary. Every
one of them was a table with too little in it to be worth a page.

The ordering itself is unchanged: pure A-Z, blanks interleaved. A school sits
where its name says it does, and at roughly a tenth of rows that reads fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 00:14:31 +01:00
TudorandClaude Opus 5 8967966eef feat(places): list schools alphabetically on place pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 1m21s
Someone on a place page is usually looking for a school they can name, so the
order should serve scanning for it rather than ranking. /api/rankings keeps
its league-table ordering; this is a place-page decision, not a site-wide one.
Sorted case-insensitively, or a capitalised name would sort ahead of every
lowercase one.

The change made five pieces of copy untrue, so they go with it. The phase
variant titled itself "— Ranked", and all four route families described
themselves as "ranked by SATs and GCSE results". A page that opens by claiming
an order it does not keep is worse than one that claims nothing.

The ItemList markup carried `position` with no declared order, which reads as
a ranking. It now declares ItemListOrderAscending, so the structured data says
what the table does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 00:09:58 +01:00
tudor 4a9a5c734b Merge pull request 'fix(e2e): three assertions that were wrong about correct behaviour' (#122) from fix/e2e-canonical-and-robots into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m26s
Reviewed-on: #122
2026-08-21 23:07:57 +00:00
TudorandClaude Opus 5 4e82e6c916 fix(e2e): three assertions that were wrong about correct behaviour
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 41s
The staging gate was red on three journeys. All three were faults in the
tests; the site was behaving correctly in each case.

Next normalises canonical URLs against trailingSlash:false, so the homepage
ships "https://www.schoolcompare.co.uk" with no slash while every other route
keeps its path. Both address the same document. The test hardcoded the slash
and so failed only on the root — /rankings and /admissions passed throughout,
which is what made it look like a homepage bug rather than a test bug.
Compared with trailing slashes stripped from both sides.

The robots.txt assertion matched "Disallow: /" anywhere in the file and
tripped over the AI-crawler groups Cloudflare injects — ClaudeBot, GPTBot,
Amazonbot and six others all carry a blanket disallow, deliberately, and none
of them is Googlebot. It now parses the file into user-agent groups and checks
only the "*" group, which is also the thing the test was always trying to say:
Google may crawl the page, so it can see the noindex header.

Both were the same mistake as the doubled brand: asserting a naive string
rather than the semantics, and asserting against what the code assembles
rather than what the page renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 23:51:34 +01:00
tudor d4340a8fdd Merge pull request 'feat(places): name every authority a place sits in' (#121) from feat/place-multiple-authorities into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #121
2026-08-21 21:56:50 +00:00
TudorandClaude Opus 5 bb2f7a5841 fix(places): address review, and merge places GIAS spells more than one way
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m34s
Two findings from review on #121, plus a third the review prompted.

The cap at three authorities silently dropped the fourth in exactly the case
where the information matters most — a genuinely fragmented place — and
contradicted the stated goal of naming every authority a place sits in. It is
gone. The share rule was always the real limit and already bounds the list at
ten. Measured against the live corpus, one town would have been truncated
today: LONDON, split evenly between Hackney, Lambeth, Westminster and
Lewisham.

parent_authority used mode() while authorities used value_counts(), and on an
exact tie pandas does not guarantee the two pick the same name, so the 301
could have pointed somewhere other than the authority named first on the page.
The parent is now derived from authorities[0]: one computation, one answer.
It also inherits the sentinel filter, so a place can no longer redirect to
/schools/authority/does-not-apply.

Chasing the truncation case surfaced a worse bug. Places were grouped by raw
town value, but the registry is keyed by slug, and GIAS spells the same place
several ways. Five town slugs come from more than one spelling: "London"
(1,819 schools) and "LONDON" (12) both slugify to `london`, so the later group
simply overwrote the earlier one — /schools/london could have shown twelve
schools, silently, depending on row order. Weston-super-Mare was split 14/19
across two spellings and Newcastle-under-Lyme across three. Grouping is now by
slug, and the display name is the most common spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:42:18 +01:00
TudorandClaude Opus 5 1cb5314c53 feat(places): name every authority a place sits in
SW19 is mostly Merton but partly Wandsworth, and the page said only Merton.
The cause was one field doing two jobs: _parent_authority takes the modal
authority, which is right for a 301 target and wrong as a statement about
where a place is.

This is not a corner case. A quarter of viable outcodes (425 of 1,760) and a
third of viable towns (263 of 783) cross an authority boundary — Bedford the
town spans Bedford and Central Bedfordshire.

Place now carries `authorities`, every authority holding at least a tenth of
the schools and at least two of them, largest first. parent_authority stays
single and unchanged, because a redirect still needs one target.

The share threshold exists because GIAS carries postcode errors: EN6 lists two
Shropshire schools among fourteen in Hertfordshire, and a bare "any authority
present" rule would print those as though they were real. A place too small or
too fragmented to clear the threshold still names its largest, so the page
never goes silent about where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:40:58 +01:00
tudor 4cea26b813 Merge pull request 'fix(places): align the measure column's heading with its values' (#120) from fix/place-table-alignment into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #120
2026-08-21 21:21:03 +00:00
TudorandClaude Opus 5 dbb74d9b60 fix(places): align the measure column's heading with its values
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
The heading sat on the right edge of the column and every value on the left.
A specificity collision, not a layout problem: the two were aligned by
different selectors and only one of them won.

  .table td            (0,1,1)  text-align: left    <- won for the value
  .num                 (0,1,0)  text-align: right   <- lost
  .table th:last-child (0,2,1)  text-align: right   <- won for the heading

The heading and the value cell now share one class and one rule, so they
cannot drift apart again whatever else changes around them.

The column also stretched to half the table. It now hugs its content with
width:1% and nowrap, so the school name takes the remaining width — which is
what made the gap read as misalignment on a wide screen, and what crowded the
name column on a narrow one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:15:02 +01:00
tudor 9545aec7f4 Merge pull request 'fix(places): phase-grouped tables, plain-English measures, styled links' (#119) from fix/place-presentation-to-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 56s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #119
2026-08-21 20:57:13 +00:00
Tudor 3365ebcb3a fix(places): phase-grouped tables, plain-English measures, styled links
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Three presentation faults on the place pages, all found by looking at a
rendered page rather than at a test.

An unphased place page showed one primary-only measure for a list holding both
phases: 8 of 27 rows on /schools/brentwood were blank, because secondaries
have no reading-writing-maths score. Picking the other measure would only have
inverted which rows were empty, and putting both in one column would have
mixed a percentage with a 0-90 score. Each phase now gets its own table, so a
blank cell means the school genuinely has no published result — which is worth
saying, and now says "Not published" rather than a bare dash.

"RWM expected" was invented here. The site already names the measure in
METRIC_DEFINITIONS, surfaced at /api/metrics: "Reading, Writing & Maths
Combined %". The heading now reads "Reading, writing & maths" with the full
definition in the tooltip.

Links carried no class at all, so they rendered as default blue underlined
browser links beside a site that styles table links as body colour with a
brand hover. They now follow RankingsView's convention, and running-copy links
take the brand colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:56:06 +01:00
tudor 6d79bd3331 Merge pull request 'fix(places): stop the place titles doubling the brand' (#117) from fix/place-title-brand-doubling into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #117
2026-08-21 20:53:14 +00:00
TudorandClaude Opus 5 24e114dee7 fix(places): stop the place titles doubling the brand
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 27s
Every place page shipped as 'Schools in Brentwood - Compare 27 Schools |
schoolcompare | schoolcompare'. The root layout's title template appends
'| schoolcompare' to any plain-string title, and all four place routes already
carried the brand. W8 opted the other routes out with an absolute title; the
place routes were written afterwards and did not inherit the lesson.

~2,600 titles affected, and the repetition pushed them past Google's
truncation point, so the doubled brand displaced real words in the result.

An e2e journey now asserts no title repeats the brand, across the static
routes and a place page, so this cannot come back on a route added later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:43:32 +01:00
tudor 6c5db0c266 Merge pull request 'fix(places): submit and link the phase variants' (#116) from fix/place-phase-variants into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #116
2026-08-21 19:46:55 +00:00
TudorandClaude Opus 5 6f749ed21f fix(places): submit and link the phase variants
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m39s
/schools/[place]/[phase] shipped as routes but reached nothing. The sitemap
emitted one URL per registry entry and the registry had no phase dimension, so
~950 pages were absent from every sitemap — and PlaceView did not link them
either, leaving them reachable by nothing at all.

That is the query shape the baseline actually showed: 'primary schools in
beccles', 'secondary schools in brentwood'. Publishing the routes without a
path in meant building for the demand and then hiding from it.

Place now carries phase_urns so the per-phase threshold can be applied without
re-querying, the sitemap emits a variant wherever a phase clears the threshold
on its own, and the API exposes the qualifying phases so the place page links
only variants that exist. Outcodes are excluded: nobody searches 'primary
schools in SW11' and those routes do not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 20:43:07 +01:00
tudor d423826840 Merge pull request 'fix(places): a locality collision must not break the sitemap' (#115) from fix/locality-collision-skip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m38s
Reviewed-on: #115
2026-08-21 19:26:02 +00:00
TudorandClaude Opus 5 d3c63ccc6d fix(places): a locality collision must not break the sitemap
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 34s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 34s
Sitemap regeneration failed on staging. 'richmond' in the curated locality
list collides with the GIAS town Richmond in North Yorkshire (37 schools), the
registry raised, and the admin endpoint 500d — taking down sitemap generation
for all 25,000 school pages over one bad row of curated data.

The guard now skips the colliding locality and logs an error. Skipping still
achieves what the guard was for — a locality never silently shadows a town —
without letting curated data break the site. That matters beyond this bug:
GIAS town names change with no code change here, so a raise could fire
spontaneously in production later.

Also removes four localities that were London boroughs rather than districts.
Hackney, Islington, Greenwich and Ealing are local authorities with 104, 72,
108 and 115 schools and already have authority pages; a locality defined by
two or three outcodes would have been a partial near-duplicate of one — the
thin-content failure the two-namespace design exists to avoid. A test now
guards the whole borough list.

Validated against the live corpus: 15 localities, no town collisions, no
authority duplicates, all 15 clear the threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 20:20:52 +01:00
tudor b93eb3a691 Merge pull request 'feat(seo): the location layer — town, locality, authority and outcode pages (W2)' (#114) from feat/w2-location-layer into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m18s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 11m8s
Reviewed-on: #114
2026-08-21 17:56:05 +00:00
TudorandClaude Opus 5 6b871ce1e9 feat(places): ItemList and BreadcrumbList, and the e2e gate
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m33s
ItemList tells Google the page is a ranked set rather than prose;
BreadcrumbList puts the place in a hierarchy. School URLs in the markup are
absolute on the canonical host, since a relative URL in JSON-LD is ambiguous.

Eight journeys covering all four families, the two-namespace guarantee, the
threshold, the canonical, the sitemap and the local-versus-England line — the
last because that comparison is the reason these pages are not a name dropped
into a template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:19:08 +01:00
TudorandClaude Opus 5 c981d89137 feat(places): town, locality, authority and outcode routes
Every generateStaticParams is gated behind PRERENDER_PLACES and wrapped in the
same try/catch the school route uses. The plan claimed authority pages were
'few enough to always prebuild' — but few enough still means the API must be
reachable at build time, and in CI it is not: the build failed with
ECONNREFUSED rather than degrading to ISR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:17:57 +01:00
TudorandClaude Opus 5 de5e790112 feat(places): place page client and view component
One component for all four families: they differ in what fills the registry,
not in what the page shows, so a second would be a second place to forget the
same change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:15:10 +01:00
TudorandClaude Opus 5 42138fc402 feat(places): submit place and outcode sitemaps
Separate children per family so Search Console reports the location layer's
indexation apart from the school pages' — which is the point of the index
built in W1, and the number the stop condition watches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:13:55 +01:00
TudorandClaude Opus 5 c5af476213 feat(places): /api/places registry and place detail endpoints
The registry is cached for the process and reset by the same admin endpoint
that rebuilds the sitemaps, so places and sitemap always describe the same
corpus rather than drifting apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:13:05 +01:00
TudorandClaude Opus 5 de853b90b3 feat(places): London localities and postcode districts
The GIAS town field puts 1,819 London schools under the single value
'London', so it cannot answer 'schools in Battersea' — a query that appears in
the baseline. No single field can: parliamentary constituency gives Battersea
but not Canary Wharf, admin_ward gives Canary Wharf but not Battersea, and
neither gives Clapham or Shoreditch. So a locality is curated, defined by the
postcode districts it covers, which needs no new ingestion.

A locality may not shadow a published town: the registry raises rather than
silently costing a page that carries real demand. One below the threshold is
logged rather than raising, because a locality can legitimately be too small.

The pipeline seed mirrors the module, with a test guarding the drift — the
same arrangement gias_codes has, and for the same reason: the backend image
does not contain pipeline/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:11:56 +01:00
TudorandClaude Opus 5 759d9f5cea feat(places): registry of towns and authorities
Two namespaces because 67 town names collide with an authority name and
neither set contains the other — postal towns cross authority boundaries, so
Bedford the town holds 104 schools against the authority's 86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:10:37 +01:00
TudorandClaude Opus 5 555d3f0a7d docs(seo): implementation plan for the W2 location layer
Seven tasks: the place registry, London localities and outcodes, the places
API, per-family sitemaps, the shared place view, the four route families, and
structured data plus the e2e gate.

Two things the plan corrects against the spec. The backend image does not
contain pipeline/, so the curated locality list cannot live only in a dbt
seed — it follows the gias_codes.py precedent instead, canonical in backend
with the seed as a mirror. And NationalAverages is nested by phase rather than
flat, which the first draft read wrongly and would have rendered every page
without its England comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:09:35 +01:00
TudorandClaude Opus 5 ecc847091c docs(seo): design for the W2 location layer
Supersedes the original spec's W2. The Search Console baseline inverted its
ordering: every measured location query is town or district level, none is an
administrative area, and phase is part of the query rather than a filter.

Two problems the original design did not anticipate. 67 viable towns share a
name with a local authority, and the authority is the larger set in only 43 of
them — postal towns cross authority boundaries, so neither can absorb the
other. Two namespaces resolve it by construction. And the GIAS town field
collapses 1,819 London schools into one value, which a curated
locality-to-outcode seed solves without new ingestion.

Sizing is measured against the live 25,185-school corpus rather than
estimated: 783 viable towns, 1,760 outcodes, 154 authorities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:09:35 +01:00
tudor b187a478c9 Merge pull request 'feat(seo): rewrite the C1 snippets to earn the click (W8)' (#113) from feat/seo-metadata-c1 into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m24s
Reviewed-on: #113
2026-08-20 23:26:12 +00:00
TudorandClaude Opus 5 c0547c45e5 feat(seo): rewrite the C1 snippets to earn the click (W8)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 11s
The baseline says these pages already rank and are not clicked. 'compare
school performance' sits at position 6.1 with 0.43% CTR; 'compare schools' at
7.2 with 0.87%. The brand query 'school compare' draws 9.16% from the same
neighbourhood of the same results page, which rules out a ranking explanation
— when the snippet gives a reason to click, it gets clicked.

These SERPs are owned by the DfE's own 'Compare school performance' service.
The old title put a lowercase brand nobody searches for in the most valuable
pixels, then a near-paraphrase of that service's name. Beside the government's
own result it read as a lookalike.

Intent in the title, differentiator in the description. Titles now match what
people type, and the descriptions carry the one fact gov.uk does not publish:
how close you had to live to get a place.

/compare deliberately takes the tool phrasing rather than the homepage's, so
the two pages stop competing for one phrase. The root layout's default and
Open Graph copy were saying something different again; they now agree.

No hard school counts in any of it. The corpus moves with every data refresh
and this repo has already shipped one copy bug of that kind.

Tests guard the mechanics — SERP length, intent keyword, the differentiator,
no brand-first title — and leave the wording free to iterate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 00:21:08 +01:00
tudor 4a3928df9f Merge pull request 'fix(seo): a school is publishable on any year's results, not the latest' (#112) from fix/sitemap-any-year-data into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 18s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m24s
Reviewed-on: #112
2026-08-20 23:15:19 +00:00
TudorandClaude Opus 5 07c97a46c5 fix(seo): a school is publishable on any year's results, not the latest
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
_school_sitemap_rows tested only the latest year's row, which quietly dropped
every school with results in its history but a null row for the most recent
year — a school that stopped reporting, or whose figures were suppressed for
small-cohort disclosure.

The Mallard Academy (150367) is the case that caught it: real KS2 results for
2015-16 through 2018-19, then null rows from 2022-23 on. Its detail page shows
all four years; the sitemap omitted it. Sampling 40 of the 2,206 excluded
schools found 4 like this, so roughly 220 real pages were being withheld.

Publishable is now a property of the school, computed across every row, while
lastmod still comes from the latest row so the most recent Ofsted date wins.
The field list is a module constant shared with _has_publishable_data so the
two checks cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 00:04:32 +01:00
tudor bb81337aba Merge pull request 'fix(seo): keep staging out of the search index' (#111) from fix/staging-noindex into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m1s
Reviewed-on: #111
2026-08-20 22:39:38 +00:00
TudorandClaude Opus 5 b34511e459 chore: record the branch cleanup manifest
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m30s
79 remote branches deleted: 77 fully merged into main, plus
feat/seo-crawl-hygiene and feat/england-only-corpus, whose content is
preserved on feat/seo-crawl-hygiene-main (PR #110).

Each line carries the SHA, so any branch can be restored with
  git push origin <sha>:refs/heads/<name>

The 14 branches left standing all carry content that differs from main and
none of them is mine to judge abandoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:23:30 +01:00
tudor f928a15c1e Merge pull request 'fix(seo): crawl hygiene and a per-family sitemap index (W1)' (#110) from feat/seo-crawl-hygiene-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 18s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m21s
Reviewed-on: #110
2026-08-20 22:23:10 +00:00
TudorandClaude Opus 5 1fc1e07d21 fix(seo): keep staging out of the search index
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 13s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Staging serves the same image as production off stx., with robots.txt saying
Allow: / and no noindex — a fully crawlable duplicate of the site. Nothing
appears indexed today, most likely because the pages canonicalise across to
production, but that is a side effect rather than a control.

X-Robots-Tag, not a robots.txt Disallow. Disallow blocks crawling, which is
not the same as blocking indexing: a disallowed URL can still be indexed from
external links, and blocking the crawl means Google never fetches the page and
so never sees a noindex at all. Staging stays crawlable and answers noindex.

Matched on the staging host explicitly rather than 'any host that is not
production'. The inverted form would cover future environments automatically,
but its failure mode is deindexing production if the Host header ever arrives
rewritten by a proxy — which cannot be verified from here. This form's failure
mode is a new environment being indexable until someone adds it, which is
recoverable. Any new non-production hostname must be added.

The journeys only ever run against staging (deploy.yml passes
STAGING_BASE_URL; promote.yml smoke-polls production without Playwright), so
asserting the header there is safe. The two assertions live in one test
because the halves only work together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:21:58 +01:00
TudorandClaude Opus 5 2208ad93c1 feat(seo): split the sitemap into a per-family index
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m2s
Search Console reports coverage per submitted sitemap, so one file per page
family is what will make W2's location pages measurable when they land. The
index's lastmod is generation time, which is the correct semantic there —
unlike on a <url>, where it would be a claim we cannot support.

Children sit under /sitemaps/ because Next only treats a whole bracketed path
segment as dynamic; a route folder named sitemap-[...parts] would be read as a
literal static segment and never match. Confirmed by the build output, which
lists /sitemaps/[...parts] as a dynamic route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 24f3cb4c65 fix(seo): submit only school pages that have something to show
Drops the schools with neither results nor an Ofsted grade, adds /admissions
which was never listed, replaces the invented priority and changefreq with a
lastmod taken from each school's Ofsted date.

lastmod is omitted where no date is known rather than defaulted to now. An
always-now lastmod is a claim Google learns to distrust; absent honestly
means unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 3816b92d06 fix(seo): noindex parameterised comparisons, keep bare /compare
25,193 schools make ~317 million pairs. The bare page stays indexable as the
landing page for the head term; the parameter space goes noindex, follow so
its outbound links still count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 51ce2d6373 fix(seo): declare a canonical on every route
The homepage read eleven search params and declared no canonical, so every
filter combination was a crawlable near-duplicate of the page we most want to
rank. Rankings and admissions declared none either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 5ddc7314fd fix(seo): canonicalise on the www host, which is the one that serves 200
The apex 301s to www at Cloudflare, but metadataBase, the school-page
canonical, robots.txt's Sitemap: line and the sitemap's own <loc> entries all
named the apex. Every one of those pointed Google at a redirect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
tudor 8ad2070e76 Merge pull request 'fix(data): drop the Welsh school GIAS does not type as Welsh' (#109) from fix/welsh-establishment-leak into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m18s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m25s
Reviewed-on: #109
2026-08-20 21:59:38 +00:00
TudorandClaude Opus 5 3aad5101a8 fix(data): drop the Welsh school GIAS does not type as Welsh
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 38s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
Beechwood College (URN 142458, Sully, CF64 5SE) survived the England-only
filter. GIAS types it a Special post 16 institution (32), not a Welsh
establishment (30), so filtering on establishment type alone left it behind —
the last Welsh school on the site, and the reason Vale of Glamorgan was still
in the authority list.

The earlier verification claimed the type codes mapped onto the Welsh
authorities in both directions. That was checked exhaustively for Cardiff and
by count for three others; Vale of Glamorgan was never checked, and it was the
one that did not hold.

LA code is the reliable discriminator: GIAS gives the 22 Welsh unitary
authorities the contiguous block 660-681, which English authorities never use.
Filtering on postcode would have been wrong — Redbrook, Tutshill, Wyedean and
two other Gloucestershire schools carry NP16/NP25 postcodes because Royal Mail
areas straddle the border, and they are English schools with English data. An
e2e test now pins those five so the fix cannot be simplified into a postcode
filter later.

The 660-681 range is documented GIAS structure this project cannot verify from
its own data, so the range filters and the authority NAME checks: if the range
is ever wrong, a Welsh authority reappears in assert_england_only_schools and
the pipeline fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:54:24 +01:00
tudor c47fe38971 Merge pull request 'feat(data): publish England only, dropping Welsh and overseas establishments' (#107) from feat/england-only-corpus into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m27s
Reviewed-on: #107
2026-08-20 21:20:13 +00:00
tudor 8f211577c8 Merge pull request 'fix(e2e): scope the Distance-tab assertion, and separate the tile figures' (#106) from fix/e2e-distance-locator into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m26s
Reviewed-on: #106
2026-08-20 21:18:28 +00:00
TudorandClaude Opus 5 69f2201244 docs(seo): correct W1 plan's sitemap child routes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Next only treats a whole bracketed path segment as dynamic, so the planned
app/sitemap-[...parts]/route.ts would have been read as a literal static
folder and never matched. Children move under /sitemaps/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:09:00 +01:00
TudorandClaude Opus 5 e6048c9ca6 docs(seo): implementation plan for W1, crawl hygiene and sitemap
Five tasks: one canonical host, canonicals on every route, noindex on
parameterised comparisons, a sitemap that drops dataless schools and invented
priorities, and a per-family sitemap index.

Planning turned up a fault the spec had missed: the apex 301s to www, but
metadataBase, the school-page canonical, robots.txt's Sitemap: line and every
sitemap <loc> named the apex. Task 1 fixes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:05:00 +01:00
TudorandClaude Opus 5 7650b16f62 feat(data): publish England only, dropping Welsh and overseas establishments
GIAS ships the whole UK plus overseas and offshore establishments. None of
them carry comparable DfE performance data — Wales does not publish on the
English measures at all — so every one of these pages rendered with null
results, null Ofsted and null phase. There were 2,036 of them: 1,569 Welsh,
123 offshore (Jersey, Guernsey, Isle of Man, Gibraltar), 316 British schools
overseas and 28 service children's schools. All 2,036 were being submitted to
search engines, alongside 29 local authorities that existed in the filters
purely to list them.

Filter at the mart boundary rather than the view layer. dim_school and
dim_location both exclude TypeOfEstablishment in {25, 26, 30, 37}, listed once
as vars.non_england_school_type_codes. Everything downstream reads those two
marts — search, the school page, /api/filters, rankings, Typesense and
build_sitemap() — so one filter removes them from the site and the sitemap
together, and Typesense drops them on its next rebuild since it recreates the
collection and swaps the alias rather than upserting in place.

coalesce rather than a bare NOT IN: a null type code would make the predicate
null and drop the row silently, and an unknown type is not grounds for
exclusion. No establishment has a null type today, but a future GIAS refresh
could ship one and the loss would be invisible.

assert_england_only_schools guards both directions: no excluded type survives
in dim_school, and dim_location holds no URN dim_school lacks — the API
inner-joins them, so the two filters drifting apart would silently shrink the
corpus.

Corpus goes from 27,229 schools to 25,193, and the authority list from 182 to
153. The 1,569 Welsh URLs now 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 21:46:28 +01:00
TudorandClaude Opus 5 9abd020967 fix(e2e): scope the Distance-tab assertion, and separate the tile figures
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 59s
The failing journey was wrong; the page was correct.

  Locator: getByRole('button', { name: 'Distance' })
  Expected: 0   Received: 1

getByRole matches accessible names by case-insensitive SUBSTRING, so
{ name: 'Distance' } matched <button>Show this distance on a map</button> —
the map toggle added by the same feature. The assertion was meant to say "no
Distance tab in the admissions segmented control" and instead said "no button
anywhere whose label contains the word distance".

Now scoped to the control it is about, via its own aria-label, and read
positively: the tab list must contain "This year" and must not contain
"Distance". An absence check against an unscoped locator passes for the wrong
reason the moment the selector stops matching, which is exactly how the
regression this test guards would return unnoticed.

Two sibling locators had the same weakness and are tightened: 'Check' is a
prefix of the button's own busy label "Checking…", and the figure matcher
accepted `(miles|m)` — a leftover from the mixed-unit era that would have kept
passing if the headline regressed to metres, which is the thing #105 just
fixed.

Tightening that matcher surfaced a real defect behind it. The cut-off figure
and its metric support are flex children with the gap drawn by CSS and nothing
between them in the text layer, so the element read "0.88 miles1.4 km" —
what a screen reader announces, and why a `miles\b` boundary could never
match. Both templates now carry an explicit space. Whitespace text nodes are
not rendered as flex items, so the reading changes and the layout does not.

Verified against staging: 54/54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 21:01:28 +01:00
tudor 228eb214f5 Merge pull request 'fix(admissions): state distance in miles throughout, never mixed' (#105) from fix/standardise-distance-units into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m28s
Reviewed-on: #105
2026-08-20 19:39:18 +00:00
TudorandClaude Opus 5 ea5249a2ea fix(admissions): state distance in miles throughout, never mixed
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 58s
The postcode check read "69 m away — inside the September 2026 cut-off of
0.17 miles". Both numbers are right and the sentence is still useless: the
reader has to convert one of them to check a comparison we had already made
for them.

The cause was a readability rule of mine in formatCutoffDistance, which swapped
to metres below 100m on the grounds that "0.04 miles" carries less than "69 m".
Taken one figure at a time that holds. Taken in a sentence containing two
figures it guarantees a mismatch whenever they fall either side of the
threshold — and a 270m cut-off with a nearby home does exactly that.

Miles now lead everywhere. It is the unit UK school admissions runs on:
councils publish cut-offs in miles (90% of the collected source rows), and it
is what a parent has already been quoted in their booklet and offer letter.
The metric figure survives only as support beside the miles figure on the
Admissions tile, where it converts the same value rather than presenting a
second one to compare.

Below 0.01 miles the decimal places run out rather than the unit being wrong,
so a very short distance is described — "under 0.01 miles" — instead of
rounding to a flat "0.00 miles", which would read as no distance at all.

Both figures in the verdict now go through one formatter with no fallback that
could reach for another unit. The old `?? "N m"` fallbacks on that line were a
second route to the same defect and are gone.

Covered by a sweep over sixty home/cut-off combinations spanning the old
switch point, asserting no verdict contains a metric reading and that exactly
two miles figures appear; plus the reported case pinned verbatim, and an e2e
guard on the rendered verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 20:35:05 +01:00
tudor ffe7e04951 Merge pull request 'feat(admissions): publish the latest cut-off only, holding history back' (#104) from feat/latest-cutoff-only into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #104
2026-08-20 17:51:18 +00:00
TudorandClaude Opus 5 c9a1892bfb feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.

The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.

Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.

What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.

Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.

One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.

The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 18:44:57 +01:00
tudor 50b599a09b Merge pull request 'fix(admissions): move the cut-off detail into its own section' (#103) from fix/admissions-section-height into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #103
2026-08-20 14:03:01 +00:00
TudorandClaude Opus 5 94151c58ea fix(admissions): move the cut-off detail into its own section
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height
of every section put together, and nearly three times the next largest — with
its default view rendering as four tiles adrift in about 1080px of blank card.

The cause was a layout trick meeting content it was never sized for. The
admissions views are stacked in one grid cell so switching them never shifts
layout, and the hidden ones keep their box: only visibility is dropped. That
works while the views are comparable. The distance view added in #102 carries
a chart, a table and a map, came to 1402px against the tile grid's 316px, and
pinned every other view to its height — including the one that renders by
default, which nobody had clicked.

Rather than only unpinning it, the detail moves out. Every other topic on the
page is a section with a nav entry, and "how close did we need to live, and
would we have got in?" is a topic, not a variant reading of the intake
figures. The headline number stays on the Admissions tile where the intake
story is; the record behind it now lives in a Distance section directly below.

  admissions   1503px -> 554px
  distance        new -> 743px   (median section on the page is ~528px)

Three further changes, each of which also makes the content better rather
than only shorter:

  * The map renders on request. Before a postcode is entered it is a circle
    drawn round a school, and it costs a Leaflet bundle and 240px to say so;
    a successful check opens it automatically, which is the point at which it
    starts answering something. Map height 320px -> 240px.
  * The chart appears only at the four published years that let the summary
    state a direction. Below that we already refuse to call the series a
    trend, and a line through three points asserts one regardless of what the
    sentence beneath it admits. The table carries those years anyway, with
    the reasons a line cannot show.
  * Three caveat paragraphs become one. They said walking-route twice and
    made the same point about priorities in two voices. It now sits in
    CutoffDistanceDetail rather than inside the check, so it still renders
    for a school with coordinates missing, where there is a table but no map
    and no check.

The new e2e guard asserts no section exceeds 2.5x the median section height.
Measuring the card's internals cannot catch this: the tile grid is flex: 1,
so it absorbs the stretch and every box still looks full. The first version
of this test targeted an arbitrary primary, passed against the live bug, and
proved nothing; pointed at a school that actually holds cut-off history it
fails on staging with "#admissions is 1459px against a 526px median".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 14:54:02 +01:00
tudor 8bf6145a73 Merge pull request 'feat(admissions): add the cut-off history, map and postcode check' (#102) from feat/last-distance-offered-full into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m14s
Reviewed-on: #102
2026-08-17 07:51:14 +00:00
TudorandClaude Opus 5 a72323874f feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Completes the last-distance-offered feature against the mockup: the
year-by-year record, the same numbers drawn over real streets, and the
reader's own address measured against them.

Serving the history
  The first cut deliberately served only the latest year, because a plain
  series would draw a trend line straight through gaps that are absences of
  publication, not of a cut-off. That reasoning is answered rather than
  abandoned: cutoffYearRows classifies every year in the span, and the chart
  breaks the line rather than interpolating across it.

  A missing year is not one fact but three. It may be unpublished; it may be
  a year the school was not oversubscribed; or there may be no record at all.
  Collapsing them into "no data" throws away the reassuring case and hides
  the important caveat, so each is stated in words in the table.

  The claim is held to what the data supports. fact_admissions.oversubscribed
  compares FIRST PREFERENCES against places, which does not establish that
  every applicant was offered one — so the copy says "places available on
  first preferences" and a test asserts the stronger claim never appears.

The trend summary is not a verdict
  It names both endpoints and their years and lets the reader conclude. It is
  withheld below four published points, and a swing under a tenth of the
  earlier figure is reported as "broadly the same" rather than dressed up as
  a direction.

The postcode check
  This is the only place on the site that answers a question about a family
  rather than a school, so most of the care went into what it refuses to say.
  postcodes.io returns a centroid covering roughly fifteen addresses, which
  against a 500 m cut-off is a fifth of the whole distance — so a margin
  inside 100 m returns "too close to call" rather than a place a family does
  not have. Unpublished years count as unknown, never as a pass. The limits
  are stated before the check is used, not revealed with the answer.

  The postcode is geocoded in the browser and never stored.

Both templates
  Banded and selective secondaries are exactly where this matters most, so
  the detail is shared. The primary page gives it a third tab; the secondary
  page is one flat panel by design and renders it inline.

Absence is explained rather than reported. A selective school's missing
figure is explained by how it admits; a consistently undersubscribed school
reads as good news.

Also makes the batch loader's test double honour ORDER BY. It was a no-op,
so "latest row per URN" was really "first row in the fixture" and the test
would have passed with the sort reversed or removed.

Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging
(the 8 cut-off journeys skip until the DAG runs). Rendered offline against
the real compiled CSS in both themes and at 390px; every new surface clears
WCAG AA, measured on composited pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-16 13:42:06 +01:00
tudor 5a71f54d94 Merge pull request 'feat(admissions): show the last distance offered where councils publish it' (#101) from feat/last-distance-offered into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 43s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 3m15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m53s
Reviewed-on: #101
2026-08-16 12:21:24 +00:00
TudorandClaude Opus 5 88c653215d feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.

There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.

Data
  * tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
    (urn, year, school_name), because school_name carries the admission
    route: (urn, year) alone collides on 118 keys and a reload would have
    silently dropped every band but one.
  * stg_school_distance applies a 25 m – 25 km plausibility band. The source
    contains 0.0-mile rows (published where a school filled on a higher
    criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
    all of which would put a visibly wrong number on a live page.
  * fact_admission_distance collapses routes to one row per school per year
    using the furthest, and keeps route_count so the page can say the figure
    is the widest of several bands rather than the one for a given child.

Serving
  * Kept out of fact_admissions: that mart is EES-derived and near-complete
    for England, this one covers 57 LAs, and the two refresh independently.
  * Latest year only. Coverage is ragged — a school may have 2021 and 2026
    and nothing between — so a history array would invite a trend line drawn
    through gaps that are absences of publication, not of a cut-off.
  * The Admissions section now renders on either source. 3% of the schools
    that render have a cut-off and no EES admissions row, and gating on
    admissions alone would have hidden the figure on those pages.

Interface
  * The year travels with the figure everywhere it appears; a cut-off
    detached from its admissions round is not a fact about anything.
  * "Not a fixed catchment — it moves every year" sits under every instance,
    because that is the inference a parent will otherwise draw.
  * Replaces a hardcoded "Historical distance cut-off data is not available
    for this school" that appeared on every secondary page, including the
    ones whose council does publish it. The absence is now stated only when
    it is real, and names the authority that would hold it.

The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.

The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-15 22:48:30 +01:00
tudor 5156a85bd1 Merge pull request 'chore: drop the hero byline, and refresh a figure in the UX audit notes' (#100) from chore/byline-removal-and-audit-figure into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m5s
Reviewed-on: #100
2026-08-15 09:25:59 +00:00
TudorandClaude Opus 5 fa1abff642 chore: drop the hero byline, and refresh a figure in the UX audit notes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
Two unrelated working-tree changes, committed as one at the owner's request.

Removes "Built for parents, by a parent." and its comment from the hero. It
was added two commits ago; taking it out is the owner's call, and the reasons
it was placed under the search rather than in the footer no longer apply.

The .heroByline rules in HomeView.module.css are deliberately left in place.
They are now unreferenced, but the class was purpose-built for this one line
and keeping it makes restoring the byline a one-line change. If the removal is
permanent, that block (and its 640px media query) should go with it.

Also updates two quoted figures in the 2026-07-02 UX audit notes from
"24,000+" to "27,000+".

Worth noting for the record: that file documents what the page said when it was
audited, and at that time it genuinely did say "24,000+" — which was itself the
bug later fixed by reading unique_schools instead of a field the API never
sent. Editing the quoted evidence makes the note read consistently with the
current site, at the cost of no longer being a verbatim record of what was
observed. Left as the owner edited it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:21:29 +01:00
tudor 38acc76555 Merge pull request 'fix(charts): make the national-average marker visible on both templates' (#99) from fix/chart-marker-contrast into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 16s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m3s
Reviewed-on: #99
2026-08-15 08:56:40 +00:00
tudor c7e0c2eac7 Merge pull request 'feat(home): swap in the higher-fidelity hero artwork' (#98) from feat/hero-artwork-v2 into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 59s
Reviewed-on: #98
2026-08-15 08:56:33 +00:00
TudorandClaude Opus 5 59ac9c10b9 fix(charts): make the national-average marker visible on both templates
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m5s
The marker was var(--brand) — the identical value to the bar fill it sits on.
Measured by pixel-sampling every bar on both templates, in both themes:

  primary   SatsChart .natTick        1.00:1  (6 bars)
  secondary .att8VizNatLine           1.00:1

Not low contrast. The same colour. It scored 5.47:1 only against the empty
track, which means it was visible precisely when a school was BELOW the
national average and vanished for every school at or above it — failing for
exactly the schools people are looking for.

No single colour fixes this, because the marker's position is data-driven: it
can land on the bar, on the empty track, or across the boundary. It is now a
knockout — a light core carrying a dark edge, both from tokens that flip with
the theme, so one part or the other always separates:

              on the bar        on the track
  light       5.47 / 9.70:1     15.17:1
  dark        7.81 / 10.85:1    13.52:1

THE LEGEND DESCRIBED A CHART THAT DID NOT EXIST

Both data swatches were var(--status-above) green while their bars were
var(--brand) teal, and the two were identical to each other — one swatch for
two series. Worse, the only swatch matching the bar colour was the one
labelled "National average", so reading the chart by matching colours told you
the teal bars were the benchmark. Each swatch now carries its bar's exact
value, and the marker swatch mirrors the knockout.

TWO SERIES, ONE COLOUR

Expected and Exceeding were both var(--brand), distinguished only by row.
They are a sequential pair — exceeding is the same cohort at a harder bar — so
they take two steps of one hue, the harder measure being the step further from
the ground in each theme.

They measure 1.77:1 (light) and 1.39:1 (dark) against each other, and that is
accepted rather than overlooked: two fills that must EACH clear 3:1 against
the same white track are geometrically forced close together. The distinction
is carried by the row labels and printed values; colour is redundant here, not
load-bearing.

THE TEST THAT SHOULD HAVE CAUGHT THIS

The WCAG journey composites backgrounds by walking the ancestor chain, but
these markers are absolutely positioned over a sibling — ancestor-walking is
structurally blind to overlap, which is why this shipped in two templates and
passed every gate. The new journey asks the stacking order instead, via
document.elementsFromPoint.

Writing it surfaced a second trap worth recording: elementsFromPoint takes
viewport coordinates and returns an empty stack off-screen, and these markers
sit ~1200px down. The first version defaulted an empty stack to white, which
made teal-on-teal look like teal-on-white and PASS in the light theme. It now
scrolls each marker into view and counts any marker it cannot resolve a
backdrop for as a failure rather than a pass.

Verified both directions: the new journey fails against current staging with
"1.00:1 over rgb(15,118,110)" in both themes, and passes against this build
with 6/6 markers measured at 5.47–15.17:1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:48:41 +01:00
TudorandClaude Opus 5 eddf74745f feat(home): swap in the higher-fidelity hero artwork
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Same 16:9 frame, but not a drop-in replacement — the composition, palette and
copy area all moved, and each one drives a change here.

COPY AREA

The old artwork was a flat #FDF9F3 down its whole left side. This one runs
peach #FEE8D2 at the top to cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream at all. --hero-ground is resampled
to #FEF2E1, the middle of the pale run where the headline and search actually
sit; the scrim covers the foliage further down.

Measured on rendered pixels with the text hidden, sampling the real glyph runs
(via Range, not the element boxes) plus a 120px growth margin:

           title      body      byline
  light    13.61:1    5.74:1    6.66:1
  dark      9.49:1    5.45:1    8.35:1

Body drops from 6.52:1 to 5.74:1 as the foliage comes closer, still clear.

BAND CROP

The schoolhouse moved to ~82% across the frame, leaving only ~140px of artwork
to its right, so the band crop is anchored to the right edge and takes 1344px
back — putting the school at 73%, which the build script now derives and
prints rather than leaving it to drift from the CSS.

The crop is also 3.2:1 rather than matching the phone. The band is not one
ratio: it runs 2.4:1 on a phone to about 3.8:1 under the one-column
breakpoint. Cropping at the narrow end means the wide end throws away height,
which cut the flag off the roof and the base off the building. Sitting above
the middle costs a little width on phones — where the crop's left is hillside —
and keeps the building whole where it matters.

BAND HEIGHTS

Raised to 13rem (861–860px) and 10rem (≤640px). The band was widest-per-height
at exactly 640px, where an 8.5rem band measured 4.2:1 — worse than any wider
viewport, because the height steps down at that breakpoint while the width does
not. Ratios across the range are now 2.0 / 2.6 / 3.6 / 3.1 / 3.8 rather than
spiking. The search still lands at 442px on a 667px viewport.

WEIGHT

Source is 3.1MB; a browser fetches one file — 29–59kB on desktop, 10–14kB on a
phone. Widths re-cut for the larger master: 2000/1400/1000 wide, 1344/900/600
band.

Verified: 0 AA failures across both themes at 1440 / 860 / 390, every srcset
entry present on disk with no orphans, tsc clean, 159/159, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:22:37 +01:00
tudor d5f3fdf0f9 Merge pull request 'feat(home): add a first-person byline under the hero search' (#97) from feat/hero-byline into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m1s
Reviewed-on: #97
2026-08-15 08:00:47 +00:00
TudorandClaude Opus 5 3015c37bac feat(home): add a first-person byline under the hero search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
"Built for parents, by a parent." — the one line on the page written by a
person rather than by a product.

Placed under the search rather than in the footer, which is where it would
have been true and unread. It does its work at the moment someone is deciding
whether to trust a page full of government statistics, which is the moment
they are looking at the search box.

Set apart without shouting: display face, a step down in size, and a short
brand rule in place of a bullet. No italic — Manrope ships none in the loaded
weights, so font-style would be synthesised into a slant, the same reason
.heroEmph resets it.

Deliberately the only claim of its kind on the page. Every other trust signal
here is about the data's provenance and is checkable against the DfE and
Ofsted; this one is about who built it, and is not. So it is stated once,
plainly, and never repeated — the opposite of the "Official & trusted" line it
now sits near, which claimed something about the site that was not true.

Measured 7.26:1 in the light theme and 8.86:1 in dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:59:43 +01:00
tudor 153b26a32f Merge pull request 'fix(home): stop implying we are official, and fix the mobile hero and search' (#96) from fix/hero-mobile-and-wording into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 56s
Reviewed-on: #96
2026-08-14 22:28:33 +00:00
tudor e373241d51 Merge pull request 'fix(e2e): assert the brand lockup and touch icon as they are actually built' (#95) from fix/e2e-brand-assertions into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m2s
Reviewed-on: #95
2026-08-14 22:28:10 +00:00
TudorandClaude Opus 5 4043270a77 fix(home): stop implying we are official, and fix the mobile hero and search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Three things, all reported from the live mobile view.

WE ARE NOT AN OFFICIAL SERVICE

The value prop read "Official & trusted", whose grammatical subject is this
site — it reads as a claim that schoolcompare is an official service. It is
not; it is an independent site that republishes official figures. Now "Built
on official data", which describes the data instead.

Checked every other use of the word: all nine describe the data ("official DfE
figures", "the official figure isn't in") and are correct as they stand. That
one title was the only place the site described itself.

The footer now says it outright — "An independent site. Not affiliated with
the Department for Education or Ofsted." — so it appears on every page rather
than being left to inference. An e2e test asserts both halves: that the
statement is present, and that no text presents the site itself as official.

The disclaimer first measured 4.71:1 against a 4.5 floor. That is a fine
margin for decoration and the wrong one for a line whose job is to be legible
to someone checking whether this is a government site; it is now 5.59:1,
quieter than the copy around it by size rather than by contrast.

THE ARTWORK SAT UNDER THE SEARCH ON PHONES

The DOM keeps .heroContent first so the desktop overlay does not depend on
source order, which left the band stranded at the bottom of the panel, reading
as a strip stuck on the end rather than a hero image. `order` moves it above
the copy on phones; it is decorative and aria-hidden, so no reading order
changes. Measured on a 667px viewport — the shortest phone still in use — the
search button lands at 418px, comfortably inside the fold.

THE SEARCH INPUT WAS UNUSABLE ON PHONES

"Search schools" is a fixed 134px with white-space: nowrap, so it took 48% of
the row. Measured on staging:

  390px   input text space 102px   placeholder needs 194px
  360px                     72px
  320px                     32px

At 320px you could not see what you were typing. The button now wraps to its
own full-width row below 480px, which fixes 360px and up — 390px goes from
102px to 226px of text space. The pill stays a single element, so it keeps its
border, shadow and :focus-within ring, and the button gains a full-width tap
target.

320px is still ~30px short. Closing it needs a shorter placeholder, which three
other tests match on by exact string; left alone deliberately rather than
churn them for a width that is effectively gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:59:12 +01:00
TudorandClaude Opus 5 e65688d600 fix(e2e): assert the brand lockup and touch icon as they are actually built
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The staging gate has been failing these two since the supplied logo artwork
replaced the reconstruction. Both tests were still asserting the previous
implementation, and both were right to fail — they were just describing
something the site no longer does.

  the header carries the schoolcompare lockup
    Looked for an <svg> inside the header link. The mark is raster now:
    <picture><source><img src="/brand/mark.png">. Nothing matched, so the
    locator timed out.

  the brand asset set is complete and served
    Requested /apple-icon, which 404s. The route moved when the generated
    app/apple-icon.tsx became a static app/apple-icon.png — generated icons
    serve at /apple-icon, static ones at /apple-icon.png with a content hash.
    The icon was present and correctly linked the whole time.

Both now read from the page instead of hardcoding the shape of the answer: the
touch icon is fetched from its own <link rel="apple-touch-icon"> href, the way
this test already handles og:image, so it follows whatever Next emits.

The lockup assertion also got stronger rather than merely corrected. A
<picture> whose sources all 404 still lays out and still satisfies
toBeVisible(), so that alone would go green on a broken lockup; it now asserts
naturalWidth, which only a decoded image can satisfy.

Verified against staging directly: 41/41 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:34:22 +01:00
tudor 7f4dfa2748 Merge pull request 'fix(home): art-direct the hero's fallback path, and declare sharp' (#94) from fix/hero-fallback-and-sharp into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m12s
Reviewed-on: #94
2026-08-14 21:28:13 +00:00
TudorandClaude Opus 5 bdaa05cd54 style(home): lift the hero artwork's dark-theme brightness to 0.75
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
At 0.52 the scene was legible but heavily suppressed; 0.75 lets the hills,
path and schoolhouse read while the white H1 stays comfortably the brightest
thing on the panel.

Re-measured rather than assumed, because in the dark theme the text is light
and the artwork is behind it — brightening the image lowers text contrast
rather than raising it. Off rendered pixels, sampling background up to 120px
past each line's right edge:

  brightness   title      body
  0.52         11.01:1    6.44:1
  0.75          9.48:1    5.21:1

Both still clear the 4.5:1 floor, body being the binding one. The trade is
recorded next to the value so the next person to reach for it knows it has a
floor and not just a taste range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:26:23 +01:00
TudorandClaude Opus 5 043506cb6b fix(home): art-direct the hero's fallback path, and declare sharp
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m32s
Two findings from review on #93, both verified before fixing.

<img src> cannot vary by viewport, so it was always the wide desktop crop. A
browser taking neither AVIF nor WebP therefore fell through to the desktop
frame on a phone and lost the schoolhouse — the exact failure the two-crop
<picture> exists to prevent, surviving in the one path nobody looks at. The
band JPEG the build script already emitted was never referenced, which was the
tell. It now backs a <source media> placed after the modern formats, so they
still win wherever they are supported.

Verified by stripping the AVIF and WebP <source>s at runtime and letting
<picture> re-resolve, which is what an old browser actually sees:

  phone    hero-band-500.avif  →  hero-band-700.jpg   (band crop, school kept)
  desktop  hero-wide-1672.avif →  hero-wide-1200.jpg

sharp was not declared: it arrives transitively from next@16.1.6, so the
documented regeneration command works today and breaks on a Next upgrade or a
clean install that resolves differently. Declared in devDependencies for the
same reason next.config.js already declares its traced font files rather than
trusting the tracer to keep finding them.

The third finding — that the hero's licence is marked unconfirmed in
CREDITS.md while the artwork ships — is accurate and deliberate. It is the
owner's to answer; recording it as unknown is the point of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:07:01 +01:00
tudor 0466986195 Merge pull request 'feat(home): use the supplied hero artwork instead of a drawn one' (#93) from feat/hero-artwork into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m14s
Reviewed-on: #93
2026-08-14 20:41:46 +00:00
TudorandClaude Opus 5 3f0e05cc99 feat(home): use the supplied hero artwork instead of a drawn one
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m29s
Replaces the hand-drawn SVG landscape with the illustration supplied by the
project owner. Master is assets/hero-source.png; everything under
public/brand/hero-* comes from scripts/build-hero-images.js and should never be
hand-edited.

LAYOUT

The artwork is composed as a full-bleed hero: it reserves an empty cream area
down its left side for the headline. The old hero was a two-column grid with
art in the right 0.88fr, which would have cropped that reserved area off and
shrunk the scene into a thumbnail — the one thing the picture is built not to
be. So the panel is now a single layered block: artwork behind, copy on top,
and below the one-column breakpoint the artwork leaves the background and
becomes a band under the search.

Overlaying does not survive down to phone widths — the panel gets too narrow
for the copy to stay inside the cream, so the scrim would have to cover nearly
the whole image and you would be left with a tinted rectangle. Hence the
switch at 860px rather than a single treatment stretched across every width.

CONTRAST

The copy sits on a gradient of --hero-ground, a new token sampled from the
artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen
points darker per channel, which leaves a visible seam straight down the hero.
The scrim exists because the artwork is a fixed image on a fluid panel: past
some width the headline would otherwise land on hillside green.

Measured on rendered pixels, sampling background up to 120px beyond the right
edge of each line, so a longer line still has margin:

  light  title 14.36:1   body 6.52:1
  dark   title 11.01:1   body 6.44:1

The worst light case is hillside green showing through the scrim at 6.52:1.

TWO CROPS

The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and
2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops,
and at the band's extreme that slices a strip through the scene and loses the
schoolhouse — exactly how the drawn hero failed on phones. <picture> switches
crop, not just resolution: the band file is pre-cropped around the school and
is already near 2.6:1, with the subject at ~65% across so squeezing toward
4.9:1 crops the empty sides instead.

WEIGHT

1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about
23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback.
`sizes` describes the real panel box (max-width 1400 minus padding) rather than
100vw, which over-requested a candidate at every width on the LCP element.

DARK THEME

A raster cannot be re-graded token by token the way the drawing was, but it
would reproduce the same failure — a bright illustration is the brightest
object on a near-black page. It is dimmed in CSS to read as dusk, and the
scrim fades it into the dark panel rather than into cream.

TESTS

The two illustration journeys are replaced by three, each covering a failure
that still renders a valid-looking page: the artwork actually loading
(naturalWidth, not src), the crop switching at the breakpoint, a modern format
winning negotiation, and the dark theme dimming it.

public/brand/CREDITS.md records provenance for every asset in that folder. The
hero's licence line is marked unconfirmed — that one needs the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:36:40 +01:00
tudor f1819e9c4d Merge pull request 'fix(home): correct what the landing page claims, and give it one rhythm' (#92) from fix/homepage-truth-and-rhythm-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m13s
Reviewed-on: #92
2026-08-14 18:18:53 +00:00
TudorandClaude Opus 5 97ac5c9cef fix(a11y): clear the four AA failures blocking the staging E2E gate
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 5m29s
Main's staging E2E has been red since #90. Two of the four failures were the
staging container serving a stale image at the moment the gate ran — apple-icon
and the header lockup both check out against staging now. The other two were
real, and both are the same mistake: a colour pairing verified against one
ground while the element sits on another.

  * .heroEyebrow  --brand on its own 10% tint over Sand is 4.19:1. The token
    clears AA on Sand, but the tint darkens the ground under the label, and
    that composite was never the thing measured. --brand-strong is 5.85:1.

  * .hiwVisual    The preview panel was grounded in Sand while everything
    inside it is a translucent status tint — and those tints are specced to
    clear AA over --bg-card. Over Sand the report-card chips landed at 4.22:1
    and 4.29:1. The ratio depended on a background two levels up. Moving the
    panel to the card surface puts them at 5.69:1 and 5.81:1; a border keeps
    it reading as an inset frame now that panel and card share a colour.

  * Footer .sectionTitle  --sage flips with the theme; the footer band does
    not (it is teal in both). So the pairing only held in one of them — the
    dark sage measured 4.08:1, on every page. Now --on-sunken-muted, which is
    what the --on-sunken-* family exists for, as Footer.tsx's own header
    comment already says.

  * .compareHeadLabel  --text-muted on the header row's tint is 4.45:1 in the
    dark theme. Under by a hair, same cause. Now --text-secondary.

The harness gained the footer, because it had no footer and so could not have
caught the one failure that appeared on all three pages. Rewriting fragments
now uses each CSS module's own hash — the footer rewritten with HomeView's
prefix renders unstyled, which would have made any contrast measured on it
meaningless while still reporting a number.

Verified: 0 AA failures in both themes across the assembled page, measured with
the same probe the e2e test uses, against the real compiled CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:08:38 +01:00
TudorandClaude Opus 5 dc22fd2853 fix(home): correct what the landing page claims, and give it one rhythm
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m25s
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>
2026-08-14 17:29:47 +01:00
tudor 6b117dd26c Merge pull request 'feat(brand): use the supplied logo artwork instead of a reconstruction' (#90) from feat/brand-logo-artwork into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 42s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m0s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m42s
Reviewed-on: #90
2026-08-08 21:41:47 +00:00
Tudor 9ee4d45a94 fix(brand): give both logo colourways one canvas so the aspect hint is right
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
LogoMark derived width from a single hardcoded 153/189, taken from mark.png,
while mark-on-dark.png was 98×112. Both call sites size the mark with
height:100%/width:auto, so the rendered size came out right in the end — but
the width/height attributes are the pre-load aspect hint, so the on-dark
artwork reserved the wrong box and reflowed on load. Any future call site
without that CSS would have rendered it stretched outright.

Per-variant attributes would not have fixed it. variant="auto" is a single
<img> whose srcset swaps the file underneath it, so one set of attributes has
to serve both colourways; the only correct fix is for the two files to share
a ratio.

They also had different artwork ratios (0.810 against 0.833) and different
padding, which the review did not reach: the logo changed size slightly
whenever the OS theme flipped. Both are now written onto one 176×208 canvas
with the artwork at the same height, centred, so they are drop-in swappable.

Measured on the built output, header mark, before → after:
  light   attr 31×38, rendered 30.75×38   →   attr 32×38, rendered 32.15×38
  dark    attr 31×38, rendered 33.25×38   →   attr 32×38, rendered 32.15×38
A 2.25px reflow and a cross-theme size jump both become 0.15px of rounding.

The share card reads the same file and had its own hardcoded 45×56, which the
re-crop would have stretched; it is now 47×56, with a comment tying it to the
canvas. The comment on ASPECT records that re-cropping either file means
re-normalising both.

Verified: tsc clean, 159/159 tests, build green, and both files report
naturalWidth 176 × naturalHeight 208 in the browser.
2026-08-08 22:39:28 +01:00
Tudor b11ee3c8de feat(brand): use the supplied logo artwork instead of a reconstruction
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m7s
The mark shipped so far was my SVG approximation, and it was wrong: the real
mark is a teardrop pin with a white window and a path flowing out of its base,
carrying three leaves — not a circle with a separate tail.

Both colourways are extracted from the supplied sheet, which has a genuinely
transparent background, so these are the artwork rather than a trace:

  public/brand/mark.png          teal pin, white window and path, green leaves
  public/brand/mark-on-dark.png  white pin with the counter knocked through

Two colourways are needed, not one. Rendered against every real ground, the
teal pin holds up on Warm White, white cards, Sand and both dark-theme grounds
— but it vanishes on the teal footer band, where only the white window
survives. The header therefore serves the teal artwork and swaps to the
on-dark artwork for dark-theme viewers through a <picture> source, needing no
JavaScript; the footer forces on-dark, because its band is teal in both
themes.

Everything downstream now derives from those two files: the favicon
(app/icon.png, replacing icon.svg), the touch icon (app/apple-icon.png,
replacing the generated apple-icon.tsx), the three PWA rasters, and the share
card, which reads public/brand/mark.png off disk so it can never drift from
the header. The logo sizing CSS keyed off a square box, which would have
squashed a 153:189 artwork — height now drives and width follows.

The wordmark stays live text in Manrope. The sheet's wordmark is a raster with
visible edge fringing, and the written style guide specifies Manrope; live
text also stays selectable, scales cleanly and recolours with the theme.

KNOWN LIMITATION: the largest instance on the sheet is 153×189. That is ample
for the header at 38px, the favicon and the share card, but short of the 512px
PWA icon, which is upscaled and slightly soft. A vector would fix it and is a
single swap — every consumer goes through components/Logo.tsx or public/brand.

Verified: tsc clean, 159/159 tests, build green, /icon.png and /apple-icon.png
emit as static routes, and the header, footer, share card and all five icons
were rendered and inspected in both themes.
2026-08-08 22:22:58 +01:00
tudor eecb84fb35 Merge pull request 'feat(brand): adopt the schoolcompare identity across the site' (#89) from feat/schoolcompare-brand into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m6s
Reviewed-on: #89
2026-08-07 17:58:55 +00:00
Tudor 39e5c11274 fix(brand): match the below-average tint to its own hue, and finish the casing
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m19s
Three review findings.

--status-below-bg carried the raw --coral tuple (249,115,96) instead of the
hue it belongs to (#A9481F → 169,72,31), so "below average" chips rendered a
coral-pink wash behind terracotta text — the one solid/tint pair in the file
whose hues disagreed. Alpha drops 0.15 → 0.12 to match --status-above-bg, so
the two chips read as equal weight; left at 0.15 the darker terracotta tint
takes the label to 4.45:1 on Warm White, under the 4.5 floor. At 0.12 it is
4.86:1 on cards and 4.67:1 on Warm White.

Auditing the whole file for the same class of error turned up one more:
--action-bg was also a --coral tint while --action is the darkened #BE3C27.
It is unused today and the dark-theme value was already correct, but it is
aligned so a future coral ghost button gets a matching label and ground. The
five phase pairs also "mismatch" by the same test and are deliberately left
alone: those pair a pastel ground (sage, sky, mustard, lavender) with a deeper
text hue, which is exactly what the guideline's Chips/Tags show.

The lowercase wordmark had only reached the visual chrome. Every user-visible
occurrence now matches: page title and template, appleWebApp title, authors,
OpenGraph and Twitter titles and siteName, the school page's siteName, the PWA
manifest name and short_name, the footer copyright line, the nav's aria-label,
the Web Share API strings, and the editorial paragraph. Comments in lib/*.ts
and AdmissionsView still say "SchoolCompare"; they describe the codebase rather
than the mark, so they are left to keep this diff to the reported problem.

The comment explaining why the font classes belong on <html> still described
--font-schibsted, which no longer exists. It now names --font-manrope and
--font-inter, and records that the e2e typeface assertion is the only guard —
this failure mode throws nothing and fails no unit test.

Verified: tsc clean, 159/159 tests, build green, and the rendered title is now
"School Admissions Guide | schoolcompare". Status and phase labels re-checked
on their own composited tints over both light grounds — all clear AA.
2026-08-07 18:33:30 +01:00
Tudor accc562b90 feat(brand): adopt the schoolcompare identity across the site
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m29s
Implements the supplied brand guideline: the pin-and-leaf mark, the lowercase
schoolcompare wordmark, Manrope for headings and Inter for everything a person
reads a number from, the deep-teal/coral/mustard/sage palette, and the rounder
geometry the guideline asks for.

The landing hero is rebuilt to the concept: a Sand panel carrying the
proposition, the search and a trust line, with the brand landscape bleeding to
the panel's right edge, followed by the four value props.

Concept vs reality — where the guideline describes something this product does
not have, the real feature takes its place and the decision is recorded in a
code comment next to it:

- The Thriving Score is not implemented. Four of its five sub-scores (pastoral,
  enrichment, community, facilities) have no source in DfE or Ofsted data, and
  its band names are Ofsted's own, which would sit beside the real Ofsted grade
  and be indistinguishable from it.
- Guides, About, Favourites and Sign in are not added to the nav — there are no
  such pages and no accounts system. Rankings and Admissions, which the
  guideline omits, keep their places because they exist.
- The fourth value prop is "Never miss a deadline" rather than "Save & revisit",
  which would need shortlisting.

Two deviations from the supplied swatches, both forced by contrast and both
confined to text and fills. Coral #F97360 under a white label measures 2.75:1
against a 4.5 floor, so --action is a darkened #BE3C27 and the original coral
survives as --coral for tints, borders and illustration. Mustard #F2C94C is
1.5:1 as text, so --mustard-ink carries labels while --mustard stays exact for
fills and rings. Every token clears AA on all three light grounds; Sand is the
binding one, so several values sit a step darker than white alone would need.

The guideline is light-only, but the site already ships a dark theme, so it is
rebuilt from the same tokens rather than dropped.

Verified: tsc clean, 159/159 unit tests, production build green, and the
generated share card and app icon render from the new mark. The hero and the
site chrome were rendered from the compiled CSS and the prerendered markup to
confirm Manrope and Inter actually resolve — a font token that silently falls
back has shipped here before.
2026-08-07 18:05:49 +01:00
tudor f31f6f8404 Merge pull request 'fix(design): correct the font assertion (serif$ matched sans-serif) and make the Leaflet overrides win' (#88) from fix/leaflet-specificity-and-font-assertion into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m8s
Reviewed-on: #88
2026-08-06 14:47:05 +00:00
TudorandClaude Opus 5 8d50afef1e fix(design): meet AA on tinted surfaces, and stop the footer inverting in dark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m6s
Full audit across home, search, rankings, admissions, compare, and primary /
secondary / special / no-data school pages, in both themes, measuring computed
styles rather than reading CSS.

Contrast: the status hues were specced against --bg-primary, but they are used
as chip text on their own tint, which sits on cards and secondary surfaces
rather than the page ground. Measured in the wild they were 3.85–4.44:1 —
under AA — on Ofsted badges, delta chips, report-card chips and metric values,
i.e. most of the product's actual signal. Darkened the light hues
(#0e6e66 → #0b625a, #9a5b00 → #7f4a00) and lifted the dark teal
(#3fb3a4 → #4fc0b0) so each clears AA on its own tint, which is the worst case
rather than the easy one. Chart and series ramps follow.

The footer was painting itself with a text token and lettering itself with a
background token: `background: var(--text-primary); color: var(--bg-secondary)`.
That reads correctly in one theme and inverts in the other — in dark mode it
became a light slab at the bottom of a dark page, with amber section headings
at 1.98:1. Added --surface-sunken and its on-* companions, which stay dark in
BOTH themes (deliberately not --surface-inverse, whose whole job is to flip),
and moved the footer onto them. Section headings were also using a status hue
purely as decoration; they are now a muted on-surface token.

The "Open full map" pill was a fixed white background with a themed text
colour, so in dark mode it rendered light violet on white at 1.84:1. Both
sides are token-driven now.

Tabular numerals are now the default for .main rather than per-component
opt-in, with prose opting back out — a handful of figures (miniNatPill,
compareRowVal, factVal) had been missed by the class-name-based pass.

Added a two-theme AA gate over home, rankings and admissions. It waits for
`transition: color` to settle first: an earlier measurement pass read
mid-transition values and reported seven failures that did not exist at rest.
Worth stating plainly — most of what a naive audit flags here is its own
artifact, and the check has to account for that to be worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:38:31 +01:00
TudorandClaude Opus 5 45a3e7fb8f fix(design): correct the font assertion and make the Leaflet overrides win
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m10s
Two follow-ups from watching the staging E2E run and re-auditing the live site.

The typeface gate failed, but the site was right. Staging now serves
"Schibsted Grotesk", "Schibsted Grotesk Fallback", …, -apple-system, sans-serif
— exactly what it should. My assertion was /Times|serif$/, and `serif$` matches
the tail of `sans-serif`, so a healthy stack could never pass. It now checks
the FIRST family in the stack (the only one actually requested) and matches
Times as a whole word. Also asserts running prose resolves to Literata, which
the earlier version never covered.

The Leaflet overrides from the previous fix never took effect. leaflet.css is
imported from a client component, so its chunk loads after globals.css; at
equal specificity (both bare `.leaflet-container`) the later sheet wins.
Verified on the live page: my rule sat at stylesheet index 0, Leaflet's at
index 3, and the attribution link was still #0078A8. Prefixing the overrides
with `html` takes them to 0,1,1, which beats a bare class regardless of load
order — injecting that on the live page turned the background to
rgb(233,232,226) and the link to rgb(88,74,155).

The palette gate would have caught this, except it skipped everything inside
.leaflet-container to avoid flagging OSM tile imagery — which also skipped
Leaflet's own chrome. Narrowed the exemption to .leaflet-tile-pane so the map
controls are now covered.

Also dropped the duplicated family name from the font tokens: next/font
already expands the variable to the family plus its metric-matched fallback,
so the stack was naming Schibsted Grotesk twice. It looked like a safety net
but wasn't — an unresolvable var() with no fallback invalidates the whole
declaration, so the literal after it never gets a turn.

Verified against live staging: body and headings resolve to Schibsted Grotesk,
prose to Literata, the search input is --text-primary rather than pure black,
and the higher-specificity Leaflet rules win when injected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:04:36 +01:00
tudor 91b49f1a59 Merge pull request 'fix(design): resolve the font tokens — staging was rendering entirely in Times' (#87) from fix/font-cascade-and-map-palette into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 58s
Reviewed-on: #87
2026-08-06 12:52:34 +00:00
TudorandClaude Opus 5 2433101fa0 fix(design): resolve the font tokens, and pull form controls and map chrome onto the palette
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m22s
Staging audit of the Cohort identity found the whole site rendering in Times.

Root cause: next/font's variable classes were on <body>, while globals.css
declares --font-display/--font-ui/--font-prose on :root as
`var(--font-schibsted), ...`. A custom property's var() references resolve on
the element that declares it, so at :root --font-schibsted was undefined,
--font-display computed to the guaranteed-invalid value, and every
font-family referencing it fell back. Nothing threw and the build was green —
the only symptom was visual.

Verified the mechanism in a browser both ways round: class on <body> gives an
empty token and a Times body font; class on <html> resolves to Schibsted
Grotesk. The classes now sit on <html>.

Two colour escapes from the same audit:

* Form controls don't inherit font or colour from their parent, so the omni
  search input and the map's "Open full map" button rendered in the system
  font at pure black. Nearly invisible against --text-primary in light mode
  and completely invisible on the dark ground. Added a base inherit rule.
* Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text
  and a #0078A8 link blue that was the most saturated colour anywhere on the
  site. The map chrome now uses tokens; the tiles stay as OSM renders them.

Three e2e gates added, because the existing suite passed while the site was
entirely in Times:

* the font tokens resolve to a non-empty value and the applied family is
  Schibsted, not a serif fallback
* no visible element renders in the browser's default black
* every rendered colour comes from the token palette — the manual audit,
  turned into a gate

Also made the og:image check environment-relative: metadataBase pins canonical
URLs to the production host, so the absolute URL pointed off-environment when
the suite ran against staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:38:17 +01:00
tudor f43a8ecb69 Merge pull request 'feat(design): adopt the Cohort identity — palette, type, mark and dark theme' (#86) from design/cohort-identity into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m1s
Reviewed-on: #86
2026-08-06 11:36:02 +00:00
TudorandClaude Opus 5 8c3a5cc4e9 fix(design): keep below/attention off the brand hue, harden the share-card fonts
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m8s
Review follow-up on #86.

The blind coral -> brand rename recreated the exact collision this PR set out
to remove: coral had been both the primary CTA and the "below average" signal,
so every negative indicator followed --primary onto iris. Sixteen rules moved
back onto the status ramp — delta chips, trend-down arrows, progress-negative
values, statusBad, chipBad/badgeBad, and the urgent deadline chips.

The Ofsted scale had also lost its worst step, with grade 4 landing on brand
while 1-2 were teal and 3 was amber. It now escalates by weight rather than by
reaching for another hue: a tinted amber chip for "requires improvement", a
solid amber one for "inadequate" (5.1:1 light, 7.7:1 dark). Report-card grade 5
follows the same rule.

globals.css now describes status as valence — teal above/good, amber
below/needs-attention — which is what it has to mean for an urgent deadline,
rather than the narrower "comparison point only" the first draft claimed.

On the share-card fonts: /opengraph-image is prerendered, so the font read
happens in the builder stage where assets/ exists, and the baked PNG ships
inside .next/standalone/.next/server/app/. File tracing independently places
the fonts at .next/standalone/assets, which the existing standalone COPY
carries to /app/assets. So the reported ENOENT doesn't occur — but it depends
on the tracer resolving a runtime join(), and a miss would be a silent 500
rather than a build failure. Declared outputFileTracingIncludes for the route
and made the Dockerfile COPY explicit so neither is left to inference.

Also repointed the immutable Cache-Control rule from the deleted favicon.svg
to app/icon.svg, where it was caching a 404.

Verified: tsc clean, 159/159 tests, clean rebuild prerenders all three image
routes with the fonts present in standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:29:53 +01:00
TudorandClaude Opus 5 8ab0ac0a04 feat(design): adopt the Cohort identity — new palette, type, mark and dark theme
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m13s
Implements the direction agreed from the identity board: Route C ("Cohort")
with the paper ground from C1, the Schibsted Grotesk / Literata pairing from
C2, and the iris accent from C3. Dark theme is in scope from the start rather
than retrofitted.

The audit found three things wrong beyond taste:

* No brand asset set. og:image was absent entirely, so every link shared into
  a class WhatsApp group rendered as a bare grey card. apple-touch-icon pointed
  at an SVG, which iOS ignores, and the manifest shipped no PNGs, so Android
  installs had no icon. The header mark and the favicon had also drifted into
  two different logos.
* No colour discipline. --primary and --trend-down were the same coral, so the
  main CTA and "below average" shared a hue. 58 distinct hex values were spread
  across component CSS, and the chart palette was still Chart.js's stock demo
  colours.
* A dark theme that was declared but never built — themeColor announced a dark
  variant with no dark styling behind it.

What changed:

Colour now has exactly three jobs that never borrow each other's hues: brand
(iris) for interactive and identity, status (teal/amber) for above/below a
comparison point, and phase for categories. Teal/amber rather than green/red
keeps the above/below signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token, and the JS-painted
surfaces (Chart.js, Leaflet) read the tokens through lib/theme so they follow
the theme instead of ignoring it.

The mark is the five-bar cohort spread — the same object as the distribution
strip inside a school row, built from opacity steps so it inverts cleanly.
components/Logo.tsx is the single source; the favicon, apple-icon and share
card all derive from its geometry.

globals.css drops 123 dead global classes left over from the vanilla-JS app
(only the btn family, .skip-link and .main were still referenced), along with
the noise overlay. It also gains prefers-reduced-motion support, which was
missing entirely, and a type scale so the 54 ad-hoc font sizes have somewhere
to converge.

Verified: tsc clean, 159 unit tests pass, production build succeeds and
prerenders /icon.svg, /apple-icon and /opengraph-image. Three e2e journeys
added for the asset set, the themeColor/background match, and the dark theme
actually repainting — all silent failures that nothing on the page reveals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:13:04 +01:00
tudor c298a29b61 Merge pull request 'fix(detail): restore the shell's mobile header/nav CSS' (#85) from perf/server-client-split into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 46s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 55s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 2s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m12s
Reviewed-on: #85
2026-08-06 09:33:34 +00:00
TudorandClaude Opus 5 e098ad4bd1 fix(detail): restore the shell's mobile header/nav CSS
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 36s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m19s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The server/client split moved the header and sticky nav markup into
SchoolDetailShell, but their responsive rules stayed behind in
schoolSections.module.css. CSS Modules hash class names per file, so those
rules stopped matching entirely: on mobile the header details block never
collapsed ("Show all details" e2e journey failed), the toggle itself never
appeared, and the section-nav mobile swap (menu button in, link row out)
never applied.

Ports the stranded blocks into SchoolDetailShell.module.css verbatim: the
768px header block (stacked header, wrapped meta pills, details toggle +
collapse) and the 640px section-nav blocks (scroll-fade mask, bottom-sheet
panel, control swap). Adds a jest guard that every class a components/school
component references is defined in the stylesheet that component imports —
it fails on the three classes that had no rule at all here
(headerDetailsOpen, atEnd, sectionNavBackLabel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:31:12 +01:00
tudor 5f961bf7f4 Merge pull request 'perf(detail): render school detail sections on the server' (#84) from perf/server-client-split into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m23s
Reviewed-on: #84
2026-08-02 21:03:32 +00:00
TudorandClaude Opus 5 803e68970c refactor(detail): drop dead derived state from the shell
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 9m28s
Leftover from the mechanical extraction: the shell still called
computeSchoolFlags() and derived isReportCard / ofstedInspectedDate /
oeifAreas / oeifAllSameGrade / deprivationDesc / primaryAvg / secondaryAvg on
every render, duplicating work page.tsx already does. None of those values
were referenced in its JSX anymore -- that logic moved to the section
composers.

The chrome needs only four locally-derived values (latestResults, phase,
isAllThrough, hasLocation), all one-liners over props it already owns.

Removing them made seven props dead, which TypeScript caught at both call
sites: absenceData, ofsted, admissions, admissionsHistory, deprivation,
finance and nationalAvg now go straight to the section composers and never
reach the client component. The shell's surface is down to schoolInfo,
yearlyData, census, navItems and children.

No behaviour change: 155 tests pass and the characterization tests remain
byte-identical to the commit that introduced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:54:59 +01:00
TudorandClaude Opus 5 ec2d12478e test(e2e): cover the detail page server/client boundary
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 14s
PR Checks / Build Frontend (no push) (pull_request) Successful in 53s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m23s
Two journeys for what the split actually changed: the admissions toggle (the
one client island inside an otherwise server-rendered section) and the sticky
nav resolving to server-rendered sections. The nav is client-rendered from a
server-computed list while the sections render on the server, so a mismatch
between the two halves would only show up in a real browser.

Existing journeys already cover all-through and special-school pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:41:18 +01:00
TudorandClaude Opus 5 4c229aec6e chore: drop unused SWR hooks and dependency
useSchools, useFilters, useMetrics and useSchoolDetails were imported by
nothing and were the only consumers of swr. All data fetching goes through
lib/api.ts on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:39:59 +01:00
TudorandClaude Opus 5 752eb07310 refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.

The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.

Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.

Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
  "Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
  still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed

The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:39:21 +01:00
TudorandClaude Opus 5 a7f6ff4035 refactor(detail): extract sections as server components
Moves ~1,300 lines of section markup out of the two client views into
components/school/, mirroring the components/compare/ layout. Twelve section
components plus shared primitives, all server components. The only client
file is AdmissionsViewToggle, which owns the hidden/aria-pressed state and
receives both views as server-rendered children.

JSX was extracted mechanically rather than retyped, so the markup the CSS
modules depend on is verbatim.

Sharing follows measured similarity, not assumption:
- Finances (91%) shared. The secondary premises-cost card is gated behind a
  prop so primary pages are unchanged; enabling it is a one-line follow-up.
- Ofsted (80%) shared, but behind a variant prop. The headline similarity hid
  a real fork: on a school with no overall grade the primary page shows a
  "Not rated" badge while the secondary shows a four-area OEIF panel, and the
  disclaimer copy differs. Both preserved exactly; reconciling them is a
  human decision, not a side effect of a move.
- Admissions (14%) and History (40%) kept separate.

Not yet wired up -- the old views still render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:33:28 +01:00
TudorandClaude Opus 5 2f786787b9 style(detail): merge the two detail stylesheets
Builds components/school/schoolSections.module.css from the primary module,
plus 35 secondary-only rules, so shared section components have one stylesheet.

Three categories of difference, handled separately so nothing changes visually:

- 7 incidental-drift properties unioned in (overflow-wrap, word-break,
  min-width, user-select) -- fixes that landed on one page and were never
  back-ported. Also fixes latent long-school-name overflow on the page that
  lacked them.
- 4 genuinely visual class differences kept as explicit variants:
  genderBar, heroStatValue, genderSplitBoys, genderSplitGirls. font-weight on
  the genderSplit pair was reclassified from drift to visual -- unioning it
  would have bolded the primary page.
- 17 rules that exist only in the secondary stylesheet but target shared class
  names (.card, .sectionTitle, .metricCard). Applied flat these would restyle
  the primary page, so they are scoped under .secondaryScope, which only
  SecondarySchoolSections will carry.

Audited: every rule in both original stylesheets is a subset of the merged one
(0 MISSING, 0 LOST), excluding the documented variants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:18:15 +01:00
TudorandClaude Opus 5 5044c24895 refactor(detail): extract derived flags and nav items to lib
Pure data-shape logic moves out of the client components so page.tsx can
compute the section list on the server without importing them.

The secondary page is not a variant of the primary one -- different section
ids (gcse, wellbeing), different flags, and History gated on more than one
year rather than at least one -- so it gets its own computeSecondaryFlags and
buildSecondaryNavItems rather than bending a shared function.

Adds 16 unit tests covering the all-through and special-school branches,
previously reachable only through a full component render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:14:35 +01:00
TudorandClaude Opus 5 d74cc95034 refactor(detail): fetch national averages on the server
Both detail views fetched /api/national-averages in a useEffect, so the
England-comparison deltas popped in after hydration and every section that
uses them was pinned to the client. The page now fetches it in parallel with
the school details (backend-cached 1h, degrades to null) and passes it down.

Removes one client round-trip per detail page and unblocks the section
extraction.

Characterization tests pass unmodified; only the render helper changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:11:08 +01:00
TudorandClaude Opus 5 7c43a1baaf test: pin school detail behaviour before refactor
14 characterization tests covering Ofsted (both OEIF and Report Card layouts),
KS2 results with the England delta, KS4 Attainment 8 / Progress 8 / EBacc,
all-through dual rendering, special-school comparison suppression (PR #70),
the admissions year/trend toggle, and conditional section rendering.

All rendering goes through renderSchoolDetail(), the single seam the
server/client split is allowed to change. The assertions must survive the
refactor unmodified.

Excludes __tests__/support from testMatch: it holds fixtures and helpers, not
suites, and the glob was failing them as empty test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:09:18 +01:00
TudorandClaude Opus 5 bcf3498086 test: add jsdom globals and school detail fixtures
jsdom provides neither IntersectionObserver nor scrollTo; both detail views
use them. Adds four fixtures (primary, secondary, all-through, special)
covering the branches the characterization tests will pin.

SchoolResult carries 57 required nullable fields, so fixtures are built from
an all-null base row and override only what they exercise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:06:14 +01:00
TudorandClaude Opus 5 7199c3a90b docs(perf): implementation plan for detail-page server/client split
Nine tasks mapping to the spec's commit sequence, each ending in an
independently testable deliverable.

Also corrects the spec's section-sharing table: measured similarity shows
Admissions (14%) and History (40%) are not shareable between the two views,
only Ofsted (80%) and Finances (91%). This costs no bytes -- the win comes
from sections being server components, not from sharing them -- and cuts the
diff and regression risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:52:56 +01:00
TudorandClaude Opus 5 97dd69a512 docs(perf): resolve CSS strategy for shared section components
Planning surfaced a gap: the two detail views' CSS modules share 79 class
names, 22 with differing rules, 16 of those used in section markup by both
views. Shared sections cannot use one stylesheet without visual change.

Resolution: union the 14 incidental-drift differences (defensive overflow
properties never back-ported between the views), and keep the 2 genuine
visual differences (.genderBar, .heroStatValue) as distinct classes behind a
variant prop. Adds an isolated CSS-merge commit so a visual regression
bisects to the merge rather than a JSX move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:47:21 +01:00
TudorandClaude Opus 5 d1c92ea7d5 docs(perf): spec for detail-page server/client split
Design for splitting SchoolDetailView (65KB) and SecondarySchoolDetailView
(46KB) into server section components behind a small client shell, following
the existing components/compare/ decomposition pattern.

Key constraint: a server component imported by a client component becomes
client, so sections are composed in page.tsx and passed through the shell as
children. Moving /api/national-averages server-side is a prerequisite, since
the England-comparison deltas feed most sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:35:54 +01:00
tudor cab7b4fe91 Merge pull request 'feat(detail): move religious character into header details + mobile reveal' (#83) from feat/detail-header-details-reveal into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 47s
Reviewed-on: #83
2026-07-24 12:15:14 +00:00
TudorandClaude Opus 4.8 96d5fcf5b0 feat(detail): move religious character into header details + mobile reveal
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
Religious character now sits with the other labelled header details (near
County / Constituency) as "Religious character: <denomination>" ("None" for
Does not apply / None), instead of as an identity chip.

On mobile/tablet (≤768px) the header details block was hidden outright; it's
now collapsed behind a "Show all details" toggle that reveals the full block
(headteacher, website, pupils, trust, phone, religious character, county,
constituency). Applied to both the primary and secondary detail views for
parity. Extends the e2e journey with the mobile toggle behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:24:16 +01:00
tudor 8cf2ac47c1 Merge pull request 'feat(detail): surface GIAS identity/contact details, drop unwired sections' (#82) from feat/detail-gias-fields-remove-unwired into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 23s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m37s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m33s
Reviewed-on: #82
2026-07-24 10:56:34 +00:00
tudor 59b5f3f163 Merge pull request 'feat(ui): viewport-aware InfoPopover replaces broken metric tooltips' (#81) from feat/info-popover-tooltip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 5m2s
Reviewed-on: #81
2026-07-22 14:48:13 +00:00
TudorandClaude Opus 4.8 8e763e39d1 test(e2e): compare help popover stays within the mobile viewport
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m33s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:56 +01:00
TudorandClaude Opus 4.8 84bca53c7e refactor(compare): row-label help uses InfoPopover, not native title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:25 +01:00
TudorandClaude Opus 4.8 15b7493b85 refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:37:33 +01:00
TudorandClaude Opus 4.8 1d9d2eb5ae feat(ui): add InfoPopover — viewport-aware metric help via Floating UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:36:23 +01:00
TudorandClaude Opus 4.8 ddb42badb6 build(deps): add @floating-ui/react for the info popover
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:35:21 +01:00
TudorandClaude Opus 4.8 702408d6a4 docs(compare): implementation plan for InfoPopover metric help
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:34:38 +01:00
TudorandClaude Opus 4.8 09d94e513e docs(compare): design for unified InfoPopover metric-help affordance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:30:40 +01:00
tudor 598ba9af4e Merge pull request 'fix(compare): a no-results school no longer blanks the trend chart' (#80) from fix/compare-null-year-blanks-chart into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 58s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m49s
Reviewed-on: #80
2026-07-22 14:23:25 +00:00
TudorandClaude Opus 4.8 dc85254ad2 fix(compare): a no-results school no longer blanks the trend chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
Adding a school with no performance data to a comparison made every
school's trend line disappear until that school was removed.

Root cause: /api/compare returns such a school with a single phantom
yearly_data row (the dim_school LEFT JOIN) whose year is null. In
buildCompareChart, Math.trunc(null) is 0, so the axis was seeded at
year 0; fillAcademicYears then walked 0, 101, 202, … and hit its
50-step cap long before reaching the real years, leaving every
school's series mapped entirely to null.

Fix: ignore yearly rows without a real numeric year when building the
axis and the per-school year map. Regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:17:43 +01:00
tudor 2f038285f6 Merge pull request 'feat(security): allow analytics subdomain to iframe the site' (#79) from feat/allow-analytics-iframe-embed into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m15s
Reviewed-on: #79
2026-07-21 21:52:34 +00:00
TudorandClaude Opus 4.8 9556595800 feat(security): allow analytics subdomain to iframe the site
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
Replace X-Frame-Options: SAMEORIGIN with a CSP frame-ancestors directive
so analytics.schoolcompare.co.uk (Umami heatmap/recorder) can embed the
site while all other origins stay blocked. X-Frame-Options cannot allow a
specific sibling subdomain (ALLOW-FROM is deprecated/ignored by modern
browsers), so frame-ancestors is the correct replacement.

Also update the nginx snippet in DEPLOYMENT.md to match, so the reverse
proxy doesn't re-inject a conflicting X-Frame-Options header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:50:44 +01:00
563 changed files with 103659 additions and 11635 deletions

No files matched your search

+13 -5
View File
@@ -20,7 +20,7 @@ PORT=80
# =============================================================================
# CORS
# =============================================================================
# Comma-separated list of allowed origins
# JSON array of allowed origins (pydantic-settings format)
# In production, only include your actual domain
ALLOWED_ORIGINS=["https://schoolcompare.co.uk"]
@@ -33,13 +33,21 @@ ADMIN_API_KEY=CHANGE_THIS_TO_A_SECURE_RANDOM_KEY
# Rate limiting (requests per minute per IP)
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_BURST=10
GLOBAL_RATE_LIMIT_PER_MINUTE=3000
# Maximum request body size in bytes (default 1MB)
MAX_REQUEST_SIZE=1048576
# =============================================================================
# API
# SEARCH AND OPTIONAL FEATURE FLAGS
# =============================================================================
DEFAULT_PAGE_SIZE=50
MAX_PAGE_SIZE=100
TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=CHANGE_THIS_TO_YOUR_TYPESENSE_KEY
# Empty URL disables Unleash-backed flags. Match the managed environment when used.
UNLEASH_URL=
UNLEASH_API_TOKEN=
# Page-size limits are currently declared by route Query parameters.
# DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE and RATE_LIMIT_BURST are not reliable tuning
# controls in the current routes; see docs/LEGACY_CODE.md.
+73 -17
View File
@@ -5,6 +5,11 @@ on:
branches:
- main
# Serialise the entire build/deploy/test cycle: no other run can move staging tags.
concurrency:
group: staging-release
cancel-in-progress: false
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
@@ -12,7 +17,18 @@ env:
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
build_id: ${{ steps.identity.outputs.build_id }}
steps:
- id: identity
run: python3 -c 'import uuid; print("build_id=" + uuid.uuid4().hex)' >> "$GITHUB_OUTPUT"
build-backend:
needs: [prepare]
outputs:
digest: ${{ steps.build.outputs.digest }}
name: Build Backend (FastAPI)
runs-on: ubuntu-latest
steps:
@@ -46,17 +62,24 @@ jobs:
type=raw,value=staging
- name: Build and push Backend Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
build-args: |
BUILD_SHA=${{ gitea.sha }}
BUILD_ID=${{ needs.prepare.outputs.build_id }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache,mode=max
build-frontend:
needs: [prepare]
outputs:
digest: ${{ steps.build.outputs.digest }}
name: Build Frontend (Next.js)
runs-on: ubuntu-latest
steps:
@@ -90,18 +113,23 @@ jobs:
type=raw,value=staging
- name: Build and push Frontend Docker image
id: build
uses: docker/build-push-action@v5
with:
context: ./nextjs-app
file: ./nextjs-app/Dockerfile
push: true
build-args: |
BUILD_SHA=${{ gitea.sha }}
BUILD_ID=${{ needs.prepare.outputs.build_id }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-args: |
FASTAPI_URL=http://backend:80/api
# Cache disabled due to registry size limits
build-pipeline:
needs: [prepare]
outputs:
digest: ${{ steps.build.outputs.digest }}
name: Build Pipeline (Meltano + dbt + Airflow)
runs-on: ubuntu-latest
steps:
@@ -135,11 +163,15 @@ jobs:
type=raw,value=staging
- name: Build and push Pipeline Docker image
id: build
uses: docker/build-push-action@v5
with:
context: ./pipeline
file: ./pipeline/Dockerfile
push: true
build-args: |
BUILD_SHA=${{ gitea.sha }}
BUILD_ID=${{ needs.prepare.outputs.build_id }}
tags: ${{ steps.meta-pipeline.outputs.tags }}
labels: ${{ steps.meta-pipeline.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
@@ -148,30 +180,23 @@ jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, build-pipeline]
needs: [prepare, build-backend, build-frontend, build-pipeline]
steps:
- name: Trigger staging stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_STAGING_WEBHOOK }}"
- name: Wait for staging to become healthy
run: |
echo "Polling ${STAGING_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${STAGING_BASE_URL}/"; then
echo "Staging is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Staging did not become healthy in time" >&2
exit 1
- uses: actions/checkout@v4
- name: Verify deployed release identity
run: python3 scripts/ci/release.py wait
env:
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
EXPECTED_SHA: ${{ gitea.sha }}
EXPECTED_BUILD_ID: ${{ needs.prepare.outputs.build_id }}
e2e-staging:
name: E2E Journeys against Staging
runs-on: ubuntu-latest
needs: [deploy-staging]
needs: [prepare, deploy-staging, build-backend, build-frontend, build-pipeline]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -187,11 +212,42 @@ jobs:
npm ci
npx playwright install --with-deps chromium
- name: Verify release before journeys
run: python3 scripts/ci/release.py wait --timeout 10
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
EXPECTED_SHA: ${{ gitea.sha }}
EXPECTED_BUILD_ID: ${{ needs.prepare.outputs.build_id }}
- name: Run E2E journeys
working-directory: e2e
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
EXPECTED_SHA: ${{ gitea.sha }}
EXPECTED_BUILD_ID: ${{ needs.prepare.outputs.build_id }}
- name: Verify release after journeys
run: python3 scripts/ci/release.py wait --timeout 10
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
EXPECTED_SHA: ${{ gitea.sha }}
EXPECTED_BUILD_ID: ${{ needs.prepare.outputs.build_id }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Mark tested image digests as verified
run: python3 scripts/ci/release.py verify
env:
EXPECTED_SHA: ${{ gitea.sha }}
EXPECTED_BUILD_ID: ${{ needs.prepare.outputs.build_id }}
BACKEND_DIGEST: ${{ needs.build-backend.outputs.digest }}
FRONTEND_DIGEST: ${{ needs.build-frontend.outputs.digest }}
PIPELINE_DIGEST: ${{ needs.build-pipeline.outputs.digest }}
# Production deployment is a second, manual approval: see promote.yml
# ("Promote to Production (manual)") and docs/DEPLOY.md.
+2 -2
View File
@@ -68,13 +68,13 @@ jobs:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt pytest "httpx<0.28"
run: pip install -r requirements.txt pytest "httpx<0.28" pyyaml
- name: Import smoke test
run: python -c "from backend.app import app; print('backend imports OK')"
- name: Backend unit tests
run: python -m pytest backend/tests -q
run: python -m pytest backend/tests pipeline/tests scripts/ci/tests -q
build-backend:
name: Build Backend (no push)
+7 -25
View File
@@ -97,33 +97,15 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag approved images as prod (keeping rollback pointer)
run: |
SHORT_SHA="${{ steps.resolve.outputs.short }}"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Resolve verified digests and promote the complete image set
run: python3 scripts/ci/release.py promote --output release.json
env:
EXPECTED_SHA: ${{ steps.resolve.outputs.full }}
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
- name: Verify production release identity
run: python3 scripts/ci/release.py wait --release release.json
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
BASE_URL: ${{ secrets.PROD_BASE_URL }}
+6
View File
@@ -5,3 +5,9 @@ __pycache__/
pipeline/transform/target/
pipeline/transform/logs/
pipeline/transform/.user.yml
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
.playwright-mcp/
# Playwright run artefacts written when the suite is run from the repo root
test-results/
@@ -0,0 +1,7 @@
[ 3229ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13147ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 29488ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 183884ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
[ 193559ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
[ 213136ms] [WARNING] Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options @ https://schoolcompare.co.uk/_next/static/chunks/b6104424f97d1001.js:0
[ 216340ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/146678-welland-primary-school:0
@@ -0,0 +1,4 @@
[ 3260ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13186ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 155792ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=B91+3:0
[ 165424ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=B91+3:0
@@ -0,0 +1,4 @@
[ 3144ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13088ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 23226ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
[ 33141ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
@@ -0,0 +1,2 @@
[ 3090ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13086ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
@@ -0,0 +1,4 @@
[ 3320ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 13262ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 31275ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 167053ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
@@ -0,0 +1,2 @@
[ 3489ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=136916&metric=attainment_8_score:0
[ 13346ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=136916&metric=attainment_8_score:0
@@ -0,0 +1,4 @@
[ 67ms] [WARNING] Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options @ https://schoolcompare.co.uk/_next/static/chunks/b6104424f97d1001.js:0
[ 3111ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/146678-welland-primary-school:0
[ 13084ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/146678-welland-primary-school:0
[ 70899ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/146678-welland-primary-school:0
@@ -0,0 +1,2 @@
[ 3297ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 13095ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
@@ -0,0 +1,3 @@
[ 3677ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 13096ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 480177ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
@@ -0,0 +1,2 @@
[ 4073ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=136916&metric=attainment_8_score:0
[ 13162ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=136916&metric=attainment_8_score:0
@@ -0,0 +1,14 @@
[ 3410ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct:0
[ 13266ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct:0
[ 41131ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct:0
[ 49979ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 59609ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 66865ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 77770ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161%2C113105:0
[ 87487ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161%2C113105:0
[ 213040ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 222647ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 240300ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161:0
[ 248166ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161%2C136916:0
[ 257801ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?metric=rwm_expected_pct&urns=142161%2C136916:0
[ 270504ms] [WARNING] Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options @ https://schoolcompare.co.uk/_next/static/chunks/b6104424f97d1001.js:0
@@ -0,0 +1,2 @@
[ 3428ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C136916&metric=rwm_expected_pct:0
[ 13145ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C136916&metric=rwm_expected_pct:0
@@ -0,0 +1,3 @@
[ 61ms] [WARNING] Deprecated API for given entry type. @ https://analytics.schoolcompare.co.uk/script.js:0
[ 3522ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C136916&metric=rwm_expected_pct:0
[ 13114ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C136916&metric=rwm_expected_pct:0
@@ -0,0 +1,5 @@
[ 3857ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C113105&metric=rwm_expected_pct:0
[ 13294ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C113105&metric=rwm_expected_pct:0
[ 73049ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C113105&metric=rwm_expected_pct:0
[ 95050ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C113105%2C124613&metric=rwm_expected_pct:0
[ 104770ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/compare?urns=142161%2C113105%2C124613&metric=rwm_expected_pct:0
@@ -0,0 +1,18 @@
[ 3222ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 13105ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 47024ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 129393ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull:0
[ 138906ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull:0
[ 173757ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 183459ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 196217ms] [WARNING] Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options @ https://schoolcompare.co.uk/_next/static/chunks/b6104424f97d1001.js:0
[ 200016ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/148084-lady-katherine-leveson-church-of-england-primary-school:0
[ 209230ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/148084-lady-katherine-leveson-church-of-england-primary-school:0
[ 215436ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 225403ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 260204ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/104049-sharmans-cross-junior-school:0
[ 270092ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/104049-sharmans-cross-junior-school:0
[ 290140ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/104049-sharmans-cross-junior-school:0
[ 1476417ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 1486368ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
[ 1506402ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?local_authority=Solihull&metric=rwm_expected_pct:0
@@ -0,0 +1,4 @@
[ 3180ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 7050ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 13109ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 23380ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
@@ -0,0 +1,2 @@
[ 3354ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13301ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
@@ -0,0 +1 @@
[ 68ms] [WARNING] Deprecated API for given entry type. @ https://analytics.schoolcompare.co.uk/script.js:0
@@ -0,0 +1 @@
[ 71ms] [WARNING] Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options @ https://schoolcompare.co.uk/_next/static/chunks/b6104424f97d1001.js:0
@@ -0,0 +1 @@
[ 68ms] [WARNING] Deprecated API for given entry type. @ https://analytics.schoolcompare.co.uk/script.js:0
@@ -0,0 +1,3 @@
[ 3407ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=B91%203DL:0
[ 13281ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=B91%203DL:0
[ 33311ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=B91%203DL:0
@@ -0,0 +1,3 @@
[ 3178ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 9456ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?postcode=B91+3DL&radius=1:0
[ 19269ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?postcode=B91+3DL&radius=1:0
@@ -0,0 +1,4 @@
[ 3422ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 13307ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 33265ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[15619214ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
@@ -0,0 +1,2 @@
[ 3300ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13157ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
@@ -0,0 +1,2 @@
[ 3263ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
[ 13184ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
@@ -0,0 +1,2 @@
[ 3142ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
[ 13108ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/1944d035eb1dfa50.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings:0
@@ -0,0 +1,2 @@
[ 5101581ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/8903db4d490d6a65.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 5101581ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
@@ -0,0 +1,4 @@
[ 3285ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 13107ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 33154ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
[ 37100ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/:0
@@ -0,0 +1,2 @@
[ 3253ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
[ 9999ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/?search=Welland+Primary:0
@@ -0,0 +1,2 @@
[ 3316ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
[ 13172ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/school/136916-the-castle-school:0
@@ -0,0 +1,2 @@
[ 3218ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?metric=rwm_high_pct:0
[ 13206ms] [WARNING] The resource https://schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://schoolcompare.co.uk/rankings?metric=rwm_high_pct:0
@@ -0,0 +1 @@
[ 77ms] [WARNING] Deprecated API for given entry type. @ https://analytics.schoolcompare.co.uk/script.js:0
@@ -0,0 +1 @@
[ 72ms] [WARNING] Deprecated API for given entry type. @ https://analytics.schoolcompare.co.uk/script.js:0
@@ -0,0 +1,2 @@
[ 3633ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=101038%2C101022%2C101036&metric=rwm_expected_pct:0
[ 13405ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=101038%2C101022%2C101036&metric=rwm_expected_pct:0
@@ -0,0 +1,10 @@
[ 336ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 338ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 351ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 3412ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
[ 10491ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 13493ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
[ 25526ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 28528ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
[ 55600ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 58602ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
@@ -0,0 +1,12 @@
[ 185ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 204ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 231ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/national-averages:0
[ 3299ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 10242ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 13245ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 25282ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 28285ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 215055ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 218057ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 260123ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/compare?urns=101038,101022:0
[ 263125ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1,2 @@
[ 3573ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
[ 13319ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
@@ -0,0 +1,2 @@
[ 166ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3388ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1 @@
[ 3248ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101038%2C101022&metric=rwm_expected_pct:0
@@ -0,0 +1,2 @@
[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://stx.schoolcompare.co.uk/search?search=primary:0
[ 3287ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/search?search=primary:0
@@ -0,0 +1,3 @@
[ 3271ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/:0
[ 8281ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 11313ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1,2 @@
[ 3730ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=137306%2C138690&metric=rwm_expected_pct:0
[ 33217ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=137306%2C138690&metric=rwm_expected_pct:0
@@ -0,0 +1,3 @@
[ 137ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3227ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 7230ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1,2 @@
[ 3272ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=137306%2C138690%2C139016&metric=rwm_expected_pct:0
[ 9317ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=137306%2C138690%2C139016&metric=rwm_expected_pct:0
@@ -0,0 +1,2 @@
[ 3495ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101037%2C101026%2C101014%2C101038%2C101022&metric=rwm_expected_pct:0
[ 33343ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=101037%2C101026%2C101014%2C101038%2C101022&metric=rwm_expected_pct:0
@@ -0,0 +1,3 @@
[ 166ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3265ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 33242ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1,3 @@
[ 210ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3561ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?postcode=B1%201BB:0
[ 33283ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?postcode=B1%201BB:0
@@ -0,0 +1,14 @@
[ 320ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3662ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?postcode=B1%201BB:0
[ 10839ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?postcode=B1%201BB:0
[ 16913ms] TypeError: r.current?.requestFullscreen is not a function
at https://stx.schoolcompare.co.uk/_next/static/chunks/a94dcea45b9ebb2c.js:1:25166
at sY (https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:160839)
at https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:166727
at tD (https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:29334)
at s3 (https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:162072)
at fC (https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:198038)
at fN (https://stx.schoolcompare.co.uk/_next/static/chunks/69be39811437728d.js:1:197860)
at eval (eval at <anonymous> (eval at evaluate (:303:30)), <anonymous>:4:16)
at eval (eval at evaluate (:303:30), <anonymous>:4:50)
at UtilityScript.evaluate (<anonymous>:305:16)
@@ -0,0 +1,5 @@
[ 213ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://stx.schoolcompare.co.uk/api/la-averages:0
[ 3418ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[ 33266ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[242896019ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
[257299531ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/?search=primary:0
@@ -0,0 +1,2 @@
[ 3717ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
[ 13359ms] [WARNING] The resource https://www.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://www.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 70ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8471/favicon.ico:0
@@ -0,0 +1 @@
[ 73ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8471/favicon.ico:0
@@ -0,0 +1,3 @@
[ 3942ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
[ 131774ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362&metric=rwm_expected_pct:0
[ 419746ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 50ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8492/favicon.ico:0
@@ -0,0 +1 @@
[ 3664ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 3236ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 37ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8493/favicon.ico:0
@@ -0,0 +1 @@
[ 42ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8494/favicon.ico:0
@@ -0,0 +1 @@
[ 40ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8495/favicon.ico:0
@@ -0,0 +1 @@
[ 3479ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 3334ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 3462ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1,2 @@
[ 3520ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=136276%2C137086%2C102052&metric=attainment_8_score:0
[26254907ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=136276%2C137086%2C102052&metric=attainment_8_score:0
@@ -0,0 +1 @@
[ 3498ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 3382ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1,3 @@
[ 3562ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
[ 84848ms] [WARNING] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently @ :5
[ 122267ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=reading_expected_pct:0
@@ -0,0 +1 @@
[ 3424ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=136276%2C137086%2C102052&metric=attainment_8_score:0
@@ -0,0 +1 @@
[ 3313ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=136276%2C137086%2C102052&metric=attainment_8_score:0
@@ -0,0 +1 @@
[ 3511ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C136276&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 3364ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C136276&metric=rwm_expected_pct:0
@@ -0,0 +1,2 @@
[ 3588ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
[ 49500ms] [WARNING] The resource https://stx.schoolcompare.co.uk/_next/static/chunks/14a927d6c03d3a7e.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://stx.schoolcompare.co.uk/compare?urns=138690%2C145362%2C100140&metric=rwm_expected_pct:0
@@ -0,0 +1 @@
[ 44ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8791/favicon.ico:0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

@@ -0,0 +1,151 @@
- generic [active] [ref=f1e1]:
- link "Skip to main content" [ref=f1e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f1e3]:
- link "SchoolCompare home" [ref=f1e5] [cursor=pointer]:
- /url: /
- img [ref=f1e7]
- generic [ref=f1e11]: SchoolCompare
- navigation "Main navigation" [ref=f1e12]:
- link "Search" [ref=f1e13] [cursor=pointer]:
- /url: /
- img [ref=f1e15]
- generic [ref=f1e18]: Search
- link "Compare" [ref=f1e19] [cursor=pointer]:
- /url: /compare
- img [ref=f1e21]
- generic [ref=f1e24]: Compare
- link "Rankings" [ref=f1e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f1e27]
- generic [ref=f1e28]: Rankings
- link "Admissions" [ref=f1e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f1e31]
- generic [ref=f1e34]: Admissions
- main [ref=f1e35]:
- generic [ref=f1e36]:
- heading "Every school in England, compared." [level=1] [ref=f1e38]:
- text: Every school in England,
- emphasis [ref=f1e39]: compared.
- generic [ref=f1e42]:
- searchbox "School name or postcode" [ref=f1e43]
- button "Search" [ref=f1e44] [cursor=pointer]
- button "Schools near me" [ref=f1e47] [cursor=pointer]:
- img [ref=f1e48]
- text: Schools near me
- generic [ref=f1e51]:
- generic [ref=f1e52]:
- generic [ref=f1e53]: Key admissions deadlines
- link "Full admissions guide →" [ref=f1e54] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e55]:
- generic [ref=f1e56]:
- generic [ref=f1e57]: Secondary · Deadline
- generic [ref=f1e59]: 121days
- generic [ref=f1e60]: Secondary applications close
- generic [ref=f1e61]: Sat, 31 October 2026
- generic [ref=f1e62]:
- generic [ref=f1e63]: Primary · Deadline
- generic [ref=f1e65]: 197days
- generic [ref=f1e66]: Primary applications close
- generic [ref=f1e67]: Fri, 15 January 2027
- generic [ref=f1e68]:
- generic [ref=f1e69]: Secondary · Offer Day
- generic [ref=f1e71]: 242days
- generic [ref=f1e72]: Secondary National Offer Day
- generic [ref=f1e73]: Mon, 1 March 2027
- generic [ref=f1e74]:
- generic [ref=f1e75]: Primary · Offer Day
- generic [ref=f1e77]: 288days
- generic [ref=f1e78]: Primary National Offer Day
- generic [ref=f1e79]: Fri, 16 April 2027
- generic [ref=f1e80]:
- generic [ref=f1e81]: Start exploring
- generic [ref=f1e82]:
- link "Top-rated primary schools" [ref=f1e83] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f1e85] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f1e87] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f1e89]:
- generic [ref=f1e90]:
- heading "What you'll see on every school" [level=2] [ref=f1e91]
- generic [ref=f1e92]: Primary or secondary — the page adapts to the phase
- generic [ref=f1e93]:
- generic [ref=f1e95]:
- generic [ref=f1e96]: Performance
- generic [ref=f1e97]: Results against the national average
- paragraph [ref=f1e98]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f1e100]:
- generic [ref=f1e101]: Judgement
- generic [ref=f1e102]: Ofsted at a glance
- paragraph [ref=f1e103]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f1e105]:
- generic [ref=f1e106]: Compare
- generic [ref=f1e107]: Side-by-side shortlists
- paragraph [ref=f1e108]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f1e110]:
- generic [ref=f1e111]:
- generic [ref=f1e112]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f1e113]
- paragraph [ref=f1e114]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f1e115]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f1e116]:
- heading "Coverage at a glance" [level=3] [ref=f1e117]
- generic [ref=f1e118]:
- generic [ref=f1e119]: Schools covered
- generic [ref=f1e120]: 24,000+
- generic [ref=f1e121]:
- generic [ref=f1e122]: Local authorities
- generic [ref=f1e123]: "182"
- generic [ref=f1e124]:
- generic [ref=f1e125]: Phases
- generic [ref=f1e126]: Primary & Secondary
- generic [ref=f1e127]:
- generic [ref=f1e128]: Latest results year
- generic [ref=f1e129]: 2024/25
- generic [ref=f1e130]:
- generic [ref=f1e131]: Historical data
- generic [ref=f1e132]: 2016–2025
- generic [ref=f1e133]:
- generic [ref=f1e134]: Metrics per school
- generic [ref=f1e135]: 40+
- contentinfo [ref=f1e136]:
- generic [ref=f1e137]:
- generic [ref=f1e138]:
- generic [ref=f1e139]:
- heading "SchoolCompare" [level=3] [ref=f1e140]
- paragraph [ref=f1e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f1e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f1e143]:
- heading "Product" [level=4] [ref=f1e144]
- list [ref=f1e145]:
- listitem [ref=f1e146]:
- link "Search schools" [ref=f1e147] [cursor=pointer]:
- /url: /
- listitem [ref=f1e148]:
- link "Rankings" [ref=f1e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f1e150]:
- link "Compare shortlist" [ref=f1e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f1e152]:
- link "Admissions guide" [ref=f1e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e154]:
- heading "Resources" [level=4] [ref=f1e155]
- list [ref=f1e156]:
- listitem [ref=f1e157]:
- link "School Performance Tables" [ref=f1e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f1e159]:
- link "Ofsted reports" [ref=f1e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f1e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f1e163]
@@ -0,0 +1,151 @@
- generic [ref=f1e1]:
- link "Skip to main content" [ref=f1e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f1e3]:
- link "SchoolCompare home" [ref=f1e5] [cursor=pointer]:
- /url: /
- img [ref=f1e7]
- generic [ref=f1e11]: SchoolCompare
- navigation "Main navigation" [ref=f1e12]:
- link "Search" [ref=f1e13] [cursor=pointer]:
- /url: /
- img [ref=f1e15]
- generic [ref=f1e18]: Search
- link "Compare" [ref=f1e19] [cursor=pointer]:
- /url: /compare
- img [ref=f1e21]
- generic [ref=f1e24]: Compare
- link "Rankings" [ref=f1e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f1e27]
- generic [ref=f1e28]: Rankings
- link "Admissions" [ref=f1e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f1e31]
- generic [ref=f1e34]: Admissions
- main [ref=f1e35]:
- generic [ref=f1e36]:
- heading "Every school in England, compared." [level=1] [ref=f1e38]:
- text: Every school in England,
- emphasis [ref=f1e39]: compared.
- generic [ref=f1e42]:
- searchbox "School name or postcode" [active] [ref=f1e43]
- button "Search" [ref=f1e44] [cursor=pointer]
- button "Schools near me" [ref=f1e47] [cursor=pointer]:
- img [ref=f1e48]
- text: Schools near me
- generic [ref=f1e51]:
- generic [ref=f1e52]:
- generic [ref=f1e53]: Key admissions deadlines
- link "Full admissions guide →" [ref=f1e54] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e55]:
- generic [ref=f1e56]:
- generic [ref=f1e57]: Secondary · Deadline
- generic [ref=f1e59]: 121days
- generic [ref=f1e60]: Secondary applications close
- generic [ref=f1e61]: Sat, 31 October 2026
- generic [ref=f1e62]:
- generic [ref=f1e63]: Primary · Deadline
- generic [ref=f1e65]: 197days
- generic [ref=f1e66]: Primary applications close
- generic [ref=f1e67]: Fri, 15 January 2027
- generic [ref=f1e68]:
- generic [ref=f1e69]: Secondary · Offer Day
- generic [ref=f1e71]: 242days
- generic [ref=f1e72]: Secondary National Offer Day
- generic [ref=f1e73]: Mon, 1 March 2027
- generic [ref=f1e74]:
- generic [ref=f1e75]: Primary · Offer Day
- generic [ref=f1e77]: 288days
- generic [ref=f1e78]: Primary National Offer Day
- generic [ref=f1e79]: Fri, 16 April 2027
- generic [ref=f1e80]:
- generic [ref=f1e81]: Start exploring
- generic [ref=f1e82]:
- link "Top-rated primary schools" [ref=f1e83] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f1e85] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f1e87] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f1e89]:
- generic [ref=f1e90]:
- heading "What you'll see on every school" [level=2] [ref=f1e91]
- generic [ref=f1e92]: Primary or secondary — the page adapts to the phase
- generic [ref=f1e93]:
- generic [ref=f1e95]:
- generic [ref=f1e96]: Performance
- generic [ref=f1e97]: Results against the national average
- paragraph [ref=f1e98]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f1e100]:
- generic [ref=f1e101]: Judgement
- generic [ref=f1e102]: Ofsted at a glance
- paragraph [ref=f1e103]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f1e105]:
- generic [ref=f1e106]: Compare
- generic [ref=f1e107]: Side-by-side shortlists
- paragraph [ref=f1e108]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f1e110]:
- generic [ref=f1e111]:
- generic [ref=f1e112]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f1e113]
- paragraph [ref=f1e114]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f1e115]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f1e116]:
- heading "Coverage at a glance" [level=3] [ref=f1e117]
- generic [ref=f1e118]:
- generic [ref=f1e119]: Schools covered
- generic [ref=f1e120]: 24,000+
- generic [ref=f1e121]:
- generic [ref=f1e122]: Local authorities
- generic [ref=f1e123]: "182"
- generic [ref=f1e124]:
- generic [ref=f1e125]: Phases
- generic [ref=f1e126]: Primary & Secondary
- generic [ref=f1e127]:
- generic [ref=f1e128]: Latest results year
- generic [ref=f1e129]: 2024/25
- generic [ref=f1e130]:
- generic [ref=f1e131]: Historical data
- generic [ref=f1e132]: 2016–2025
- generic [ref=f1e133]:
- generic [ref=f1e134]: Metrics per school
- generic [ref=f1e135]: 40+
- contentinfo [ref=f1e136]:
- generic [ref=f1e137]:
- generic [ref=f1e138]:
- generic [ref=f1e139]:
- heading "SchoolCompare" [level=3] [ref=f1e140]
- paragraph [ref=f1e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f1e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f1e143]:
- heading "Product" [level=4] [ref=f1e144]
- list [ref=f1e145]:
- listitem [ref=f1e146]:
- link "Search schools" [ref=f1e147] [cursor=pointer]:
- /url: /
- listitem [ref=f1e148]:
- link "Rankings" [ref=f1e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f1e150]:
- link "Compare shortlist" [ref=f1e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f1e152]:
- link "Admissions guide" [ref=f1e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e154]:
- heading "Resources" [level=4] [ref=f1e155]
- list [ref=f1e156]:
- listitem [ref=f1e157]:
- link "School Performance Tables" [ref=f1e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f1e159]:
- link "Ofsted reports" [ref=f1e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f1e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f1e163]
@@ -0,0 +1,151 @@
- generic [ref=f1e1]:
- link "Skip to main content" [ref=f1e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f1e3]:
- link "SchoolCompare home" [ref=f1e5] [cursor=pointer]:
- /url: /
- img [ref=f1e7]
- generic [ref=f1e11]: SchoolCompare
- navigation "Main navigation" [ref=f1e12]:
- link "Search" [ref=f1e13] [cursor=pointer]:
- /url: /
- img [ref=f1e15]
- generic [ref=f1e18]: Search
- link "Compare" [ref=f1e19] [cursor=pointer]:
- /url: /compare
- img [ref=f1e21]
- generic [ref=f1e24]: Compare
- link "Rankings" [ref=f1e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f1e27]
- generic [ref=f1e28]: Rankings
- link "Admissions" [ref=f1e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f1e31]
- generic [ref=f1e34]: Admissions
- main [ref=f1e35]:
- generic [ref=f1e36]:
- heading "Every school in England, compared." [level=1] [ref=f1e38]:
- text: Every school in England,
- emphasis [ref=f1e39]: compared.
- generic [ref=f1e42]:
- searchbox "School name or postcode" [active] [ref=f1e43]: Welland Primary
- button "Search" [ref=f1e44] [cursor=pointer]
- button "Schools near me" [ref=f1e47] [cursor=pointer]:
- img [ref=f1e48]
- text: Schools near me
- generic [ref=f1e51]:
- generic [ref=f1e52]:
- generic [ref=f1e53]: Key admissions deadlines
- link "Full admissions guide →" [ref=f1e54] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e55]:
- generic [ref=f1e56]:
- generic [ref=f1e57]: Secondary · Deadline
- generic [ref=f1e59]: 121days
- generic [ref=f1e60]: Secondary applications close
- generic [ref=f1e61]: Sat, 31 October 2026
- generic [ref=f1e62]:
- generic [ref=f1e63]: Primary · Deadline
- generic [ref=f1e65]: 197days
- generic [ref=f1e66]: Primary applications close
- generic [ref=f1e67]: Fri, 15 January 2027
- generic [ref=f1e68]:
- generic [ref=f1e69]: Secondary · Offer Day
- generic [ref=f1e71]: 242days
- generic [ref=f1e72]: Secondary National Offer Day
- generic [ref=f1e73]: Mon, 1 March 2027
- generic [ref=f1e74]:
- generic [ref=f1e75]: Primary · Offer Day
- generic [ref=f1e77]: 288days
- generic [ref=f1e78]: Primary National Offer Day
- generic [ref=f1e79]: Fri, 16 April 2027
- generic [ref=f1e80]:
- generic [ref=f1e81]: Start exploring
- generic [ref=f1e82]:
- link "Top-rated primary schools" [ref=f1e83] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f1e85] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f1e87] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f1e89]:
- generic [ref=f1e90]:
- heading "What you'll see on every school" [level=2] [ref=f1e91]
- generic [ref=f1e92]: Primary or secondary — the page adapts to the phase
- generic [ref=f1e93]:
- generic [ref=f1e95]:
- generic [ref=f1e96]: Performance
- generic [ref=f1e97]: Results against the national average
- paragraph [ref=f1e98]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f1e100]:
- generic [ref=f1e101]: Judgement
- generic [ref=f1e102]: Ofsted at a glance
- paragraph [ref=f1e103]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f1e105]:
- generic [ref=f1e106]: Compare
- generic [ref=f1e107]: Side-by-side shortlists
- paragraph [ref=f1e108]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f1e110]:
- generic [ref=f1e111]:
- generic [ref=f1e112]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f1e113]
- paragraph [ref=f1e114]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f1e115]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f1e116]:
- heading "Coverage at a glance" [level=3] [ref=f1e117]
- generic [ref=f1e118]:
- generic [ref=f1e119]: Schools covered
- generic [ref=f1e120]: 24,000+
- generic [ref=f1e121]:
- generic [ref=f1e122]: Local authorities
- generic [ref=f1e123]: "182"
- generic [ref=f1e124]:
- generic [ref=f1e125]: Phases
- generic [ref=f1e126]: Primary & Secondary
- generic [ref=f1e127]:
- generic [ref=f1e128]: Latest results year
- generic [ref=f1e129]: 2024/25
- generic [ref=f1e130]:
- generic [ref=f1e131]: Historical data
- generic [ref=f1e132]: 2016–2025
- generic [ref=f1e133]:
- generic [ref=f1e134]: Metrics per school
- generic [ref=f1e135]: 40+
- contentinfo [ref=f1e136]:
- generic [ref=f1e137]:
- generic [ref=f1e138]:
- generic [ref=f1e139]:
- heading "SchoolCompare" [level=3] [ref=f1e140]
- paragraph [ref=f1e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f1e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f1e143]:
- heading "Product" [level=4] [ref=f1e144]
- list [ref=f1e145]:
- listitem [ref=f1e146]:
- link "Search schools" [ref=f1e147] [cursor=pointer]:
- /url: /
- listitem [ref=f1e148]:
- link "Rankings" [ref=f1e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f1e150]:
- link "Compare shortlist" [ref=f1e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f1e152]:
- link "Admissions guide" [ref=f1e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e154]:
- heading "Resources" [level=4] [ref=f1e155]
- list [ref=f1e156]:
- listitem [ref=f1e157]:
- link "School Performance Tables" [ref=f1e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f1e159]:
- link "Ofsted reports" [ref=f1e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f1e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f1e163]
@@ -0,0 +1,108 @@
- generic [active] [ref=f1e1]:
- link "Skip to main content" [ref=f1e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f1e3]:
- link "SchoolCompare home" [ref=f1e5] [cursor=pointer]:
- /url: /
- img [ref=f1e7]
- generic [ref=f1e11]: SchoolCompare
- navigation "Main navigation" [ref=f1e12]:
- link "Search" [ref=f1e13] [cursor=pointer]:
- /url: /
- img [ref=f1e15]
- generic [ref=f1e18]: Search
- link "Compare" [ref=f1e19] [cursor=pointer]:
- /url: /compare
- img [ref=f1e21]
- generic [ref=f1e24]: Compare
- link "Rankings" [ref=f1e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f1e27]
- generic [ref=f1e28]: Rankings
- link "Admissions" [ref=f1e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f1e31]
- generic [ref=f1e34]: Admissions
- main [ref=f1e35]:
- generic [ref=f1e36]:
- generic [ref=f1e40]:
- generic [ref=f1e42]:
- searchbox "School name or postcode" [ref=f1e43]: Welland Primary
- button "Search" [ref=f1e44] [cursor=pointer]
- generic [ref=f1e164]:
- combobox [ref=f1e165] [cursor=pointer]:
- option "All Phases" [selected]
- option "Primary"
- button "Advanced" [ref=f1e166] [cursor=pointer]: Advanced
- button "Clear" [ref=f1e168] [cursor=pointer]
- generic [ref=f1e169]:
- generic [ref=f1e170]:
- heading "1 school found" [level=2] [ref=f1e171]
- combobox [ref=f1e173] [cursor=pointer]:
- 'option "Sort: Relevance" [selected]'
- option "Highest Reading, Writing & Maths %"
- option "Lowest Reading, Writing & Maths %"
- option "Name A–Z"
- generic [ref=f1e175]:
- text: "Search: Welland Primary"
- link "×" [ref=f1e176] [cursor=pointer]:
- /url: /
- generic [ref=f1e178]:
- generic [ref=f1e179]:
- generic [ref=f1e180]:
- link "Welland Primary School" [ref=f1e181] [cursor=pointer]:
- /url: /school/146678-welland-primary-school
- generic [ref=f1e182]: Good · 2023
- generic [ref=f1e183]:
- generic [ref=f1e184]: Primary
- generic [ref=f1e185]: Academy converter
- generic [ref=f1e186]: Ages 4–11
- generic [ref=f1e187]:
- generic [ref=f1e188]:
- strong [ref=f1e189]: 74%
- 'generic "Previous year: 76.0%" [ref=f1e190]':
- img "Trend down" [ref=f1e191]
- generic [ref=f1e193]: Reading, Writing & Maths
- generic [ref=f1e194]: +12 pts vs national
- generic [ref=f1e195]:
- strong [ref=f1e196]: "131"
- generic [ref=f1e197]: pupils
- generic [ref=f1e199]: Worcestershire
- generic [ref=f1e200]:
- link "View" [ref=f1e201] [cursor=pointer]:
- /url: /school/146678-welland-primary-school
- button "+ Compare" [ref=f1e202] [cursor=pointer]
- contentinfo [ref=f1e136]:
- generic [ref=f1e137]:
- generic [ref=f1e138]:
- generic [ref=f1e139]:
- heading "SchoolCompare" [level=3] [ref=f1e140]
- paragraph [ref=f1e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f1e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f1e143]:
- heading "Product" [level=4] [ref=f1e144]
- list [ref=f1e145]:
- listitem [ref=f1e146]:
- link "Search schools" [ref=f1e147] [cursor=pointer]:
- /url: /
- listitem [ref=f1e148]:
- link "Rankings" [ref=f1e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f1e150]:
- link "Compare shortlist" [ref=f1e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f1e152]:
- link "Admissions guide" [ref=f1e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f1e154]:
- heading "Resources" [level=4] [ref=f1e155]
- list [ref=f1e156]:
- listitem [ref=f1e157]:
- link "School Performance Tables" [ref=f1e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f1e159]:
- link "Ofsted reports" [ref=f1e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f1e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f1e163]
@@ -0,0 +1,268 @@
- generic [active] [ref=f2e1]:
- link "Skip to main content" [ref=f2e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f2e3]:
- link "SchoolCompare home" [ref=f2e5] [cursor=pointer]:
- /url: /
- img [ref=f2e7]
- generic [ref=f2e11]: SchoolCompare
- navigation "Main navigation" [ref=f2e12]:
- link "Search" [ref=f2e13] [cursor=pointer]:
- /url: /
- img [ref=f2e15]
- generic [ref=f2e18]: Search
- link "Compare" [ref=f2e19] [cursor=pointer]:
- /url: /compare
- img [ref=f2e21]
- generic [ref=f2e24]: Compare
- link "Rankings" [ref=f2e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f2e27]
- generic [ref=f2e28]: Rankings
- link "Admissions" [ref=f2e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f2e31]
- generic [ref=f2e34]: Admissions
- main [ref=f2e35]:
- generic [ref=f2e36]:
- button "Back" [ref=f2e37] [cursor=pointer]:
- generic [ref=f2e38]: ←
- text: Back
- generic [ref=f2e39]:
- generic [ref=f2e40]:
- generic [ref=f2e41]:
- img [ref=f2e45] [cursor=pointer]
- generic [ref=f2e48]:
- link "Leaflet" [ref=f2e49] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f2e50]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f2e54] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f2e55] [cursor=pointer]:
- generic [ref=f2e56]:
- img [ref=f2e57]
- text: Open full map
- generic [ref=f2e59]:
- generic [ref=f2e60]:
- heading "Welland Primary School" [level=1] [ref=f2e61]
- generic [ref=f2e62]:
- generic [ref=f2e63]: Worcestershire
- generic [ref=f2e64]: Academy converter
- paragraph [ref=f2e65]:
- text: Marlbank Road, Welland, Malvern, WR13 6NE ·
- button "View on map ↗" [ref=f2e66] [cursor=pointer]
- button "+ Add to Compare" [ref=f2e68] [cursor=pointer]
- navigation "Page sections" [ref=f2e69]:
- button "Back to top" [ref=f2e70] [cursor=pointer]:
- generic [ref=f2e71]: ↑
- button "Section Ofsted" [ref=f2e72] [cursor=pointer]:
- generic [ref=f2e73]:
- generic [ref=f2e74]: Section
- generic [ref=f2e75]: Ofsted
- generic [ref=f2e76]: ▾
- generic [ref=f2e77]:
- heading "Ofsted Rating Inspected 6 June 2023 Ofsted reports ↗" [level=2] [ref=f2e78]:
- text: Ofsted Rating
- generic [ref=f2e79]: Inspected 6 June 2023
- link "Ofsted reports ↗" [ref=f2e80] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/146678
- generic [ref=f2e82]: Good
- paragraph [ref=f2e83]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
- paragraph [ref=f2e84]:
- text: Rated
- strong [ref=f2e85]: Good
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f2e86]:
- heading "SATs Results (2024/25)" [level=2] [ref=f2e87]
- paragraph [ref=f2e88]: End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.
- generic [ref=f2e89]:
- generic [ref=f2e90]:
- generic [ref=f2e91]: Reading, Writing & Maths combined
- generic [ref=f2e92]:
- text: 74.0%
- generic [ref=f2e93]: +12 pts
- generic [ref=f2e94]: "National avg: 62%"
- generic [ref=f2e95]:
- generic [ref=f2e96]: Exceeding expected level (Reading, Writing & Maths)
- generic [ref=f2e97]:
- text: 11.0%
- generic [ref=f2e98]: +3 pts
- generic [ref=f2e99]: "National avg: 8%"
- generic [ref=f2e100]:
- generic [ref=f2e101]: "?"
- generic [ref=f2e102]:
- generic [ref=f2e103]:
- text: Why is combined lower? A pupil is only counted if they met the bar in
- strong [ref=f2e104]: all three
- text: subjects. Some passed reading but not writing; some passed writing but not maths.
- generic [ref=f2e105]:
- generic [ref=f2e106]:
- text: Reading
- strong [ref=f2e107]: 74%
- generic [ref=f2e108]: ·
- generic [ref=f2e109]:
- text: Writing
- strong [ref=f2e110]: 74%
- generic [ref=f2e111]: ·
- generic [ref=f2e112]:
- text: Maths
- strong [ref=f2e113]: 81%
- generic [ref=f2e114]: →
- generic [ref=f2e115]:
- text: All three
- strong [ref=f2e116]: 74%
- generic [ref=f2e117]:
- generic [ref=f2e118]:
- generic [ref=f2e119]:
- generic [ref=f2e120]: Reading
- generic [ref=f2e121]:
- generic:
- generic: 75%
- generic [ref=f2e122]:
- generic [ref=f2e124]:
- generic [ref=f2e125]: Expected
- generic [ref=f2e126]: 74%
- generic [ref=f2e130]:
- generic [ref=f2e131]: Exceeding
- generic [ref=f2e132]: 48%
- generic [ref=f2e135]:
- generic [ref=f2e137]: 0%
- generic [ref=f2e139]: 25%
- generic [ref=f2e141]: 50%
- generic [ref=f2e143]: 75%
- generic [ref=f2e145]: 100%
- generic [ref=f2e146]:
- generic [ref=f2e147]: Writing
- generic [ref=f2e148]:
- generic:
- generic: 72%
- generic [ref=f2e149]:
- generic [ref=f2e151]:
- generic [ref=f2e152]: Expected
- generic [ref=f2e153]: 74%
- generic [ref=f2e157]:
- generic [ref=f2e158]: Exceeding
- generic [ref=f2e159]: 11%
- generic [ref=f2e162]:
- generic [ref=f2e164]: 0%
- generic [ref=f2e166]: 25%
- generic [ref=f2e168]: 50%
- generic [ref=f2e170]: 75%
- generic [ref=f2e172]: 100%
- generic [ref=f2e173]:
- generic [ref=f2e174]: Maths
- generic [ref=f2e175]:
- generic:
- generic: 74%
- generic [ref=f2e176]:
- generic [ref=f2e178]:
- generic [ref=f2e179]: Expected
- generic [ref=f2e180]: 81%
- generic [ref=f2e184]:
- generic [ref=f2e185]: Exceeding
- generic [ref=f2e186]: 33%
- generic [ref=f2e189]:
- generic [ref=f2e191]: 0%
- generic [ref=f2e193]: 25%
- generic [ref=f2e195]: 50%
- generic [ref=f2e197]: 75%
- generic [ref=f2e199]: 100%
- generic [ref=f2e200]:
- generic [ref=f2e201]: Expected standard
- generic [ref=f2e203]: Exceeding / high score
- generic [ref=f2e205]: National average
- generic [ref=f2e207]:
- generic [ref=f2e208]:
- heading "Admissions" [level=2] [ref=f2e209]
- group "Admissions view" [ref=f2e210]:
- button "This year" [pressed] [ref=f2e211] [cursor=pointer]
- button "8-year trend" [ref=f2e212] [cursor=pointer]
- generic [ref=f2e215]:
- generic [ref=f2e216]:
- definition [ref=f2e217]: "21"
- term [ref=f2e218]: Places offered
- generic [ref=f2e219]:
- definition [ref=f2e220]: "19"
- term [ref=f2e221]: Wanted it first
- generic [ref=f2e222]:
- definition [ref=f2e223]:
- text: "19"
- generic [ref=f2e224]: of 19 · 100.0%
- term [ref=f2e225]: Got their first choice
- generic [ref=f2e226]:
- definition [ref=f2e227]: "45"
- term [ref=f2e228]: Applied in total
- generic [ref=f2e229]:
- heading "Pupils & Inclusion" [level=2] [ref=f2e230]
- generic [ref=f2e231]:
- generic [ref=f2e232]:
- generic [ref=f2e233]: Eligible for pupil premium
- generic [ref=f2e234]: 26.0%
- generic [ref=f2e235]: Pupils from disadvantaged backgrounds
- generic [ref=f2e236]:
- generic [ref=f2e237]: English as an additional language
- generic [ref=f2e238]: 0.0%
- generic [ref=f2e239]:
- generic [ref=f2e240]: Pupils receiving SEN support
- generic [ref=f2e241]: 4.0%
- generic [ref=f2e242]:
- generic [ref=f2e243]: Boys and girls
- generic [ref=f2e244]:
- generic [ref=f2e245]: 48%
- generic [ref=f2e246]: girls
- generic [ref=f2e247]: ·
- generic [ref=f2e248]: 52%
- generic [ref=f2e249]: boys
- 'img "Gender split: 48% girls, 52% boys" [ref=f2e250]'
- generic [ref=f2e253]: 64 girls, 70 boys
- generic [ref=f2e254]:
- heading "Results Over Time" [level=2] [ref=f2e255]
- generic [ref=f2e257]:
- generic [ref=f2e258]: ↓ Reading, Writing & Maths peaked at 81% (2022/23), currently 74%
- generic [ref=f2e259]:
- generic [ref=f2e260]: KS2 SATs · Reading, Writing & Maths
- tablist "Select metric" [ref=f2e261]:
- tab "At expected level" [selected] [ref=f2e262] [cursor=pointer]
- tab "Above expected level" [ref=f2e263] [cursor=pointer]
- tab "Pupil progress" [ref=f2e264] [cursor=pointer]
- img [ref=f2e266]
- paragraph [ref=f2e267]: "* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19."
- group [ref=f2e268]:
- generic "▸ View raw year-by-year data" [ref=f2e269] [cursor=pointer]
- contentinfo [ref=f2e270]:
- generic [ref=f2e271]:
- generic [ref=f2e272]:
- generic [ref=f2e273]:
- heading "SchoolCompare" [level=3] [ref=f2e274]
- paragraph [ref=f2e275]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f2e276] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f2e277]:
- heading "Product" [level=4] [ref=f2e278]
- list [ref=f2e279]:
- listitem [ref=f2e280]:
- link "Search schools" [ref=f2e281] [cursor=pointer]:
- /url: /
- listitem [ref=f2e282]:
- link "Rankings" [ref=f2e283] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f2e284]:
- link "Compare shortlist" [ref=f2e285] [cursor=pointer]:
- /url: /compare
- listitem [ref=f2e286]:
- link "Admissions guide" [ref=f2e287] [cursor=pointer]:
- /url: /admissions
- generic [ref=f2e288]:
- heading "Resources" [level=4] [ref=f2e289]
- list [ref=f2e290]:
- listitem [ref=f2e291]:
- link "School Performance Tables" [ref=f2e292] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f2e293]:
- link "Ofsted reports" [ref=f2e294] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f2e296]: © 2026 SchoolCompare.co.uk
- alert [ref=f2e297]
@@ -0,0 +1,151 @@
- generic [active] [ref=f3e1]:
- link "Skip to main content" [ref=f3e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e3]:
- link "SchoolCompare home" [ref=f3e5] [cursor=pointer]:
- /url: /
- img [ref=f3e7]
- generic [ref=f3e11]: SchoolCompare
- navigation "Main navigation" [ref=f3e12]:
- link "Search" [ref=f3e13] [cursor=pointer]:
- /url: /
- img [ref=f3e15]
- generic [ref=f3e18]: Search
- link "Compare" [ref=f3e19] [cursor=pointer]:
- /url: /compare
- img [ref=f3e21]
- generic [ref=f3e24]: Compare
- link "Rankings" [ref=f3e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f3e27]
- generic [ref=f3e28]: Rankings
- link "Admissions" [ref=f3e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f3e31]
- generic [ref=f3e34]: Admissions
- main [ref=f3e35]:
- generic [ref=f3e36]:
- heading "Every school in England, compared." [level=1] [ref=f3e38]:
- text: Every school in England,
- emphasis [ref=f3e39]: compared.
- generic [ref=f3e42]:
- searchbox "School name or postcode" [ref=f3e43]
- button "Search" [ref=f3e44] [cursor=pointer]
- button "Schools near me" [ref=f3e47] [cursor=pointer]:
- img [ref=f3e48]
- text: Schools near me
- generic [ref=f3e51]:
- generic [ref=f3e52]:
- generic [ref=f3e53]: Key admissions deadlines
- link "Full admissions guide →" [ref=f3e54] [cursor=pointer]:
- /url: /admissions
- generic [ref=f3e55]:
- generic [ref=f3e56]:
- generic [ref=f3e57]: Secondary · Deadline
- generic [ref=f3e59]: 121days
- generic [ref=f3e60]: Secondary applications close
- generic [ref=f3e61]: Sat, 31 October 2026
- generic [ref=f3e62]:
- generic [ref=f3e63]: Primary · Deadline
- generic [ref=f3e65]: 197days
- generic [ref=f3e66]: Primary applications close
- generic [ref=f3e67]: Fri, 15 January 2027
- generic [ref=f3e68]:
- generic [ref=f3e69]: Secondary · Offer Day
- generic [ref=f3e71]: 242days
- generic [ref=f3e72]: Secondary National Offer Day
- generic [ref=f3e73]: Mon, 1 March 2027
- generic [ref=f3e74]:
- generic [ref=f3e75]: Primary · Offer Day
- generic [ref=f3e77]: 288days
- generic [ref=f3e78]: Primary National Offer Day
- generic [ref=f3e79]: Fri, 16 April 2027
- generic [ref=f3e80]:
- generic [ref=f3e81]: Start exploring
- generic [ref=f3e82]:
- link "Top-rated primary schools" [ref=f3e83] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f3e85] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f3e87] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f3e89]:
- generic [ref=f3e90]:
- heading "What you'll see on every school" [level=2] [ref=f3e91]
- generic [ref=f3e92]: Primary or secondary — the page adapts to the phase
- generic [ref=f3e93]:
- generic [ref=f3e95]:
- generic [ref=f3e96]: Performance
- generic [ref=f3e97]: Results against the national average
- paragraph [ref=f3e98]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f3e100]:
- generic [ref=f3e101]: Judgement
- generic [ref=f3e102]: Ofsted at a glance
- paragraph [ref=f3e103]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f3e105]:
- generic [ref=f3e106]: Compare
- generic [ref=f3e107]: Side-by-side shortlists
- paragraph [ref=f3e108]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f3e110]:
- generic [ref=f3e111]:
- generic [ref=f3e112]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f3e113]
- paragraph [ref=f3e114]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f3e115]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f3e116]:
- heading "Coverage at a glance" [level=3] [ref=f3e117]
- generic [ref=f3e118]:
- generic [ref=f3e119]: Schools covered
- generic [ref=f3e120]: 24,000+
- generic [ref=f3e121]:
- generic [ref=f3e122]: Local authorities
- generic [ref=f3e123]: "182"
- generic [ref=f3e124]:
- generic [ref=f3e125]: Phases
- generic [ref=f3e126]: Primary & Secondary
- generic [ref=f3e127]:
- generic [ref=f3e128]: Latest results year
- generic [ref=f3e129]: 2024/25
- generic [ref=f3e130]:
- generic [ref=f3e131]: Historical data
- generic [ref=f3e132]: 2016–2025
- generic [ref=f3e133]:
- generic [ref=f3e134]: Metrics per school
- generic [ref=f3e135]: 40+
- contentinfo [ref=f3e136]:
- generic [ref=f3e137]:
- generic [ref=f3e138]:
- generic [ref=f3e139]:
- heading "SchoolCompare" [level=3] [ref=f3e140]
- paragraph [ref=f3e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f3e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f3e143]:
- heading "Product" [level=4] [ref=f3e144]
- list [ref=f3e145]:
- listitem [ref=f3e146]:
- link "Search schools" [ref=f3e147] [cursor=pointer]:
- /url: /
- listitem [ref=f3e148]:
- link "Rankings" [ref=f3e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f3e150]:
- link "Compare shortlist" [ref=f3e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f3e152]:
- link "Admissions guide" [ref=f3e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f3e154]:
- heading "Resources" [level=4] [ref=f3e155]
- list [ref=f3e156]:
- listitem [ref=f3e157]:
- link "School Performance Tables" [ref=f3e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f3e159]:
- link "Ofsted reports" [ref=f3e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f3e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f3e163]
@@ -0,0 +1,151 @@
- generic [ref=f3e1]:
- link "Skip to main content" [ref=f3e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e3]:
- link "SchoolCompare home" [ref=f3e5] [cursor=pointer]:
- /url: /
- img [ref=f3e7]
- generic [ref=f3e11]: SchoolCompare
- navigation "Main navigation" [ref=f3e12]:
- link "Search" [ref=f3e13] [cursor=pointer]:
- /url: /
- img [ref=f3e15]
- generic [ref=f3e18]: Search
- link "Compare" [ref=f3e19] [cursor=pointer]:
- /url: /compare
- img [ref=f3e21]
- generic [ref=f3e24]: Compare
- link "Rankings" [ref=f3e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f3e27]
- generic [ref=f3e28]: Rankings
- link "Admissions" [ref=f3e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f3e31]
- generic [ref=f3e34]: Admissions
- main [ref=f3e35]:
- generic [ref=f3e36]:
- heading "Every school in England, compared." [level=1] [ref=f3e38]:
- text: Every school in England,
- emphasis [ref=f3e39]: compared.
- generic [ref=f3e42]:
- searchbox "School name or postcode" [active] [ref=f3e43]: B91 3
- button "Search" [ref=f3e44] [cursor=pointer]
- button "Schools near me" [ref=f3e47] [cursor=pointer]:
- img [ref=f3e48]
- text: Schools near me
- generic [ref=f3e51]:
- generic [ref=f3e52]:
- generic [ref=f3e53]: Key admissions deadlines
- link "Full admissions guide →" [ref=f3e54] [cursor=pointer]:
- /url: /admissions
- generic [ref=f3e55]:
- generic [ref=f3e56]:
- generic [ref=f3e57]: Secondary · Deadline
- generic [ref=f3e59]: 121days
- generic [ref=f3e60]: Secondary applications close
- generic [ref=f3e61]: Sat, 31 October 2026
- generic [ref=f3e62]:
- generic [ref=f3e63]: Primary · Deadline
- generic [ref=f3e65]: 197days
- generic [ref=f3e66]: Primary applications close
- generic [ref=f3e67]: Fri, 15 January 2027
- generic [ref=f3e68]:
- generic [ref=f3e69]: Secondary · Offer Day
- generic [ref=f3e71]: 242days
- generic [ref=f3e72]: Secondary National Offer Day
- generic [ref=f3e73]: Mon, 1 March 2027
- generic [ref=f3e74]:
- generic [ref=f3e75]: Primary · Offer Day
- generic [ref=f3e77]: 288days
- generic [ref=f3e78]: Primary National Offer Day
- generic [ref=f3e79]: Fri, 16 April 2027
- generic [ref=f3e80]:
- generic [ref=f3e81]: Start exploring
- generic [ref=f3e82]:
- link "Top-rated primary schools" [ref=f3e83] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f3e85] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f3e87] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f3e89]:
- generic [ref=f3e90]:
- heading "What you'll see on every school" [level=2] [ref=f3e91]
- generic [ref=f3e92]: Primary or secondary — the page adapts to the phase
- generic [ref=f3e93]:
- generic [ref=f3e95]:
- generic [ref=f3e96]: Performance
- generic [ref=f3e97]: Results against the national average
- paragraph [ref=f3e98]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f3e100]:
- generic [ref=f3e101]: Judgement
- generic [ref=f3e102]: Ofsted at a glance
- paragraph [ref=f3e103]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f3e105]:
- generic [ref=f3e106]: Compare
- generic [ref=f3e107]: Side-by-side shortlists
- paragraph [ref=f3e108]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f3e110]:
- generic [ref=f3e111]:
- generic [ref=f3e112]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f3e113]
- paragraph [ref=f3e114]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f3e115]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f3e116]:
- heading "Coverage at a glance" [level=3] [ref=f3e117]
- generic [ref=f3e118]:
- generic [ref=f3e119]: Schools covered
- generic [ref=f3e120]: 24,000+
- generic [ref=f3e121]:
- generic [ref=f3e122]: Local authorities
- generic [ref=f3e123]: "182"
- generic [ref=f3e124]:
- generic [ref=f3e125]: Phases
- generic [ref=f3e126]: Primary & Secondary
- generic [ref=f3e127]:
- generic [ref=f3e128]: Latest results year
- generic [ref=f3e129]: 2024/25
- generic [ref=f3e130]:
- generic [ref=f3e131]: Historical data
- generic [ref=f3e132]: 2016–2025
- generic [ref=f3e133]:
- generic [ref=f3e134]: Metrics per school
- generic [ref=f3e135]: 40+
- contentinfo [ref=f3e136]:
- generic [ref=f3e137]:
- generic [ref=f3e138]:
- generic [ref=f3e139]:
- heading "SchoolCompare" [level=3] [ref=f3e140]
- paragraph [ref=f3e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f3e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f3e143]:
- heading "Product" [level=4] [ref=f3e144]
- list [ref=f3e145]:
- listitem [ref=f3e146]:
- link "Search schools" [ref=f3e147] [cursor=pointer]:
- /url: /
- listitem [ref=f3e148]:
- link "Rankings" [ref=f3e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f3e150]:
- link "Compare shortlist" [ref=f3e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f3e152]:
- link "Admissions guide" [ref=f3e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f3e154]:
- heading "Resources" [level=4] [ref=f3e155]
- list [ref=f3e156]:
- listitem [ref=f3e157]:
- link "School Performance Tables" [ref=f3e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f3e159]:
- link "Ofsted reports" [ref=f3e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f3e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f3e163]
@@ -0,0 +1,277 @@
- generic [ref=f3e1]:
- link "Skip to main content" [ref=f3e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e3]:
- link "SchoolCompare home" [ref=f3e5] [cursor=pointer]:
- /url: /
- img [ref=f3e7]
- generic [ref=f3e11]: SchoolCompare
- navigation "Main navigation" [ref=f3e12]:
- link "Search" [ref=f3e13] [cursor=pointer]:
- /url: /
- img [ref=f3e15]
- generic [ref=f3e18]: Search
- link "Compare" [ref=f3e19] [cursor=pointer]:
- /url: /compare
- img [ref=f3e21]
- generic [ref=f3e24]: Compare
- link "Rankings" [ref=f3e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f3e27]
- generic [ref=f3e28]: Rankings
- link "Admissions" [ref=f3e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f3e31]
- generic [ref=f3e34]: Admissions
- main [ref=f3e35]:
- generic [ref=f3e36]:
- generic [ref=f3e40]:
- generic [ref=f3e42]:
- searchbox "School name or postcode" [active] [ref=f3e43]: B91 3
- button "Search" [ref=f3e44] [cursor=pointer]
- generic [ref=f3e164]:
- combobox [ref=f3e165] [cursor=pointer]:
- option "All Phases" [selected]
- option "16 plus"
- option "All-through"
- option "Primary"
- option "Secondary"
- button "Advanced" [ref=f3e166] [cursor=pointer]: Advanced
- button "Clear" [ref=f3e168] [cursor=pointer]
- generic [ref=f3e169]:
- generic [ref=f3e170]:
- heading "8 schools found" [level=2] [ref=f3e171]
- combobox [ref=f3e173] [cursor=pointer]:
- 'option "Sort: Relevance" [selected]'
- option "Highest Reading, Writing & Maths %"
- option "Lowest Reading, Writing & Maths %"
- option "Highest Attainment 8"
- option "Lowest Attainment 8"
- option "Name A–Z"
- generic [ref=f3e175]:
- text: "Search: B91 3"
- link "×" [ref=f3e176] [cursor=pointer]:
- /url: /
- generic [ref=f3e177]:
- generic [ref=f3e178]:
- generic [ref=f3e179]:
- generic [ref=f3e180]:
- link "Tudor Grange Academy, Solihull" [ref=f3e181] [cursor=pointer]:
- /url: /school/136310-tudor-grange-academy-solihull
- generic [ref=f3e182]: Inspected · 2025
- generic [ref=f3e183]:
- generic [ref=f3e184]: Secondary
- generic [ref=f3e185]: Academy converter
- generic [ref=f3e186]: Ages 11–18
- generic [ref=f3e187]: Sixth form
- generic [ref=f3e188]: Selective
- generic [ref=f3e189]:
- generic [ref=f3e190]:
- strong [ref=f3e191]: "58.7"
- generic [ref=f3e192]: Attainment 8
- generic [ref=f3e193]: +14.4 vs LA avg
- generic [ref=f3e194]:
- strong [ref=f3e195]: "278"
- generic [ref=f3e196]: pupils
- generic [ref=f3e198]: Solihull
- generic [ref=f3e199]:
- link "View" [ref=f3e200] [cursor=pointer]:
- /url: /school/136310-tudor-grange-academy-solihull
- button "+ Compare" [ref=f3e201] [cursor=pointer]
- generic [ref=f3e202]:
- generic [ref=f3e203]:
- generic [ref=f3e204]:
- link "Solihull School" [ref=f3e205] [cursor=pointer]:
- /url: /school/104124-solihull-school
- generic [ref=f3e206]: Not yet inspected
- generic [ref=f3e207]:
- generic [ref=f3e208]: All-through
- generic [ref=f3e209]: Other independent school
- generic [ref=f3e210]: Ages 3–19
- generic [ref=f3e211]: Selective
- generic [ref=f3e212]:
- generic [ref=f3e213]:
- strong [ref=f3e214]: "41.4"
- generic [ref=f3e215]: Attainment 8
- generic [ref=f3e216]: "-2.9 vs LA avg"
- generic [ref=f3e217]:
- strong [ref=f3e218]: "148"
- generic [ref=f3e219]: pupils
- generic [ref=f3e221]: Solihull
- generic [ref=f3e222]:
- link "View" [ref=f3e223] [cursor=pointer]:
- /url: /school/104124-solihull-school
- button "+ Compare" [ref=f3e224] [cursor=pointer]
- generic [ref=f3e225]:
- generic [ref=f3e226]:
- generic [ref=f3e227]:
- link "St Peter's Catholic School" [ref=f3e228] [cursor=pointer]:
- /url: /school/148094-st-peters-catholic-school
- generic [ref=f3e229]: Inspected · 2025
- generic [ref=f3e230]:
- generic [ref=f3e231]: Secondary
- generic [ref=f3e232]: Academy converter
- generic [ref=f3e233]: Ages 11–18
- generic [ref=f3e234]: Sixth form
- generic [ref=f3e235]: Selective
- generic [ref=f3e236]:
- generic [ref=f3e237]:
- strong [ref=f3e238]: "53.4"
- generic [ref=f3e239]: Attainment 8
- generic [ref=f3e240]: +9.1 vs LA avg
- generic [ref=f3e241]:
- strong [ref=f3e242]: "204"
- generic [ref=f3e243]: pupils
- generic [ref=f3e245]: Solihull
- generic [ref=f3e246]:
- link "View" [ref=f3e247] [cursor=pointer]:
- /url: /school/148094-st-peters-catholic-school
- button "+ Compare" [ref=f3e248] [cursor=pointer]
- generic [ref=f3e249]:
- generic [ref=f3e250]:
- generic [ref=f3e251]:
- link "Widney Junior School" [ref=f3e252] [cursor=pointer]:
- /url: /school/104054-widney-junior-school
- generic [ref=f3e253]: Good · 2024
- generic [ref=f3e254]:
- generic [ref=f3e255]: Primary
- generic [ref=f3e256]: Community school
- generic [ref=f3e257]: Ages 7–11
- generic [ref=f3e258]:
- generic [ref=f3e259]:
- strong [ref=f3e260]: 80%
- 'generic "Previous year: 78.0%" [ref=f3e261]':
- img "Trend up" [ref=f3e262]
- generic [ref=f3e264]: Reading, Writing & Maths
- generic [ref=f3e265]: +18 pts vs national
- generic [ref=f3e266]:
- strong [ref=f3e267]: "327"
- generic [ref=f3e268]: pupils
- generic [ref=f3e270]: Solihull
- generic [ref=f3e271]:
- link "View" [ref=f3e272] [cursor=pointer]:
- /url: /school/104054-widney-junior-school
- button "+ Compare" [ref=f3e273] [cursor=pointer]
- generic [ref=f3e274]:
- generic [ref=f3e275]:
- generic [ref=f3e276]:
- link "St Alphege Church of England Junior School" [ref=f3e277] [cursor=pointer]:
- /url: /school/104092-st-alphege-church-of-england-junior-school
- generic [ref=f3e278]: Inspected · 2025
- generic [ref=f3e279]:
- generic [ref=f3e280]: Primary
- generic [ref=f3e281]: Voluntary aided school
- generic [ref=f3e282]: Ages 7–11
- generic [ref=f3e283]: Church of England
- generic [ref=f3e284]:
- generic [ref=f3e285]:
- strong [ref=f3e286]: 86%
- 'generic "Previous year: 84.0%" [ref=f3e287]':
- img "Trend up" [ref=f3e288]
- generic [ref=f3e290]: Reading, Writing & Maths
- generic [ref=f3e291]: +24 pts vs national
- generic [ref=f3e292]:
- strong [ref=f3e293]: "257"
- generic [ref=f3e294]: pupils
- generic [ref=f3e296]: Solihull
- generic [ref=f3e297]:
- link "View" [ref=f3e298] [cursor=pointer]:
- /url: /school/104092-st-alphege-church-of-england-junior-school
- button "+ Compare" [ref=f3e299] [cursor=pointer]
- generic [ref=f3e300]:
- generic [ref=f3e301]:
- generic [ref=f3e302]:
- link "St Augustine's Catholic Primary School" [ref=f3e303] [cursor=pointer]:
- /url: /school/148087-st-augustines-catholic-primary-school
- generic [ref=f3e304]: Good · 2023
- generic [ref=f3e305]:
- generic [ref=f3e306]: Primary
- generic [ref=f3e307]: Academy converter
- generic [ref=f3e308]: Ages 3–11
- generic [ref=f3e309]: Roman Catholic
- generic [ref=f3e310]:
- generic [ref=f3e311]:
- strong [ref=f3e312]: 84%
- 'generic "Previous year: 79.0%" [ref=f3e313]':
- img "Trend up" [ref=f3e314]
- generic [ref=f3e316]: Reading, Writing & Maths
- generic [ref=f3e317]: +22 pts vs national
- generic [ref=f3e318]:
- strong [ref=f3e319]: "231"
- generic [ref=f3e320]: pupils
- generic [ref=f3e322]: Solihull
- generic [ref=f3e323]:
- link "View" [ref=f3e324] [cursor=pointer]:
- /url: /school/148087-st-augustines-catholic-primary-school
- button "+ Compare" [ref=f3e325] [cursor=pointer]
- generic [ref=f3e326]:
- generic [ref=f3e327]:
- generic [ref=f3e328]:
- link "St Alphege Church of England Infant and Nursery School" [ref=f3e329] [cursor=pointer]:
- /url: /school/104090-st-alphege-church-of-england-infant-and-nursery-school
- generic [ref=f3e330]: Inspected · 2025
- generic [ref=f3e331]:
- generic [ref=f3e332]: Primary
- generic [ref=f3e333]: Voluntary aided school
- generic [ref=f3e334]: Ages 3–7
- generic [ref=f3e335]: Church of England
- generic [ref=f3e337]:
- strong [ref=f3e338]: —
- generic [ref=f3e339]: Reading, Writing & Maths
- generic [ref=f3e341]: Solihull
- generic [ref=f3e342]:
- link "View" [ref=f3e343] [cursor=pointer]:
- /url: /school/104090-st-alphege-church-of-england-infant-and-nursery-school
- button "+ Compare" [ref=f3e344] [cursor=pointer]
- generic [ref=f3e345]:
- generic [ref=f3e346]:
- generic [ref=f3e347]:
- link "Solihull Sixth Form College" [ref=f3e348] [cursor=pointer]:
- /url: /school/144887-solihull-sixth-form-college
- generic [ref=f3e349]: Not yet inspected
- generic [ref=f3e350]:
- generic [ref=f3e351]: Post-16
- generic [ref=f3e352]: Academy 16-19 converter
- generic [ref=f3e353]: Ages 16–19
- generic [ref=f3e355]:
- strong [ref=f3e356]: —
- generic [ref=f3e357]: Reading, Writing & Maths
- generic [ref=f3e359]: Solihull
- generic [ref=f3e360]:
- link "View" [ref=f3e361] [cursor=pointer]:
- /url: /school/144887-solihull-sixth-form-college
- button "+ Compare" [ref=f3e362] [cursor=pointer]
- contentinfo [ref=f3e136]:
- generic [ref=f3e137]:
- generic [ref=f3e138]:
- generic [ref=f3e139]:
- heading "SchoolCompare" [level=3] [ref=f3e140]
- paragraph [ref=f3e141]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f3e142] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f3e143]:
- heading "Product" [level=4] [ref=f3e144]
- list [ref=f3e145]:
- listitem [ref=f3e146]:
- link "Search schools" [ref=f3e147] [cursor=pointer]:
- /url: /
- listitem [ref=f3e148]:
- link "Rankings" [ref=f3e149] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f3e150]:
- link "Compare shortlist" [ref=f3e151] [cursor=pointer]:
- /url: /compare
- listitem [ref=f3e152]:
- link "Admissions guide" [ref=f3e153] [cursor=pointer]:
- /url: /admissions
- generic [ref=f3e154]:
- heading "Resources" [level=4] [ref=f3e155]
- list [ref=f3e156]:
- listitem [ref=f3e157]:
- link "School Performance Tables" [ref=f3e158] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f3e159]:
- link "Ofsted reports" [ref=f3e160] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f3e162]: © 2026 SchoolCompare.co.uk
- alert [ref=f3e163]
@@ -0,0 +1,229 @@
- generic [active] [ref=f4e1]:
- link "Skip to main content" [ref=f4e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e3]:
- generic [ref=f4e4]:
- link "SchoolCompare home" [ref=f4e5] [cursor=pointer]:
- /url: /
- img [ref=f4e7]
- generic [ref=f4e11]: SchoolCompare
- navigation "Main navigation" [ref=f4e12]:
- link "Search" [ref=f4e13] [cursor=pointer]:
- /url: /
- link "Compare" [ref=f4e14] [cursor=pointer]:
- /url: /compare
- link "Rankings" [ref=f4e15] [cursor=pointer]:
- /url: /rankings
- link "Admissions" [ref=f4e16] [cursor=pointer]:
- /url: /admissions
- main [ref=f4e17]:
- generic [ref=f4e18]:
- generic [ref=f4e19]:
- generic [ref=f4e20]: Updated with 2026/2027 admissions results
- heading "Every school in England, compared." [level=1] [ref=f4e22]:
- text: Every school in England,
- emphasis [ref=f4e23]: compared.
- paragraph [ref=f4e24]:
- strong [ref=f4e25]: 24,000+ primary and secondary schools
- text: with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
- generic [ref=f4e28]:
- searchbox "School name or postcode" [ref=f4e29]
- button "Search" [ref=f4e30] [cursor=pointer]
- button "Schools near me" [ref=f4e33] [cursor=pointer]:
- img [ref=f4e34]
- text: Schools near me
- generic [ref=f4e37]:
- generic [ref=f4e38]:
- generic [ref=f4e39]: Key admissions deadlines
- link "Full admissions guide →" [ref=f4e40] [cursor=pointer]:
- /url: /admissions
- generic [ref=f4e41]:
- generic [ref=f4e42]:
- generic [ref=f4e43]: Secondary · Deadline
- generic [ref=f4e45]: 121days
- generic [ref=f4e46]: Secondary applications close
- generic [ref=f4e47]: Sat, 31 October 2026
- generic [ref=f4e48]:
- generic [ref=f4e49]: Primary · Deadline
- generic [ref=f4e51]: 197days
- generic [ref=f4e52]: Primary applications close
- generic [ref=f4e53]: Fri, 15 January 2027
- generic [ref=f4e54]:
- generic [ref=f4e55]: Secondary · Offer Day
- generic [ref=f4e57]: 242days
- generic [ref=f4e58]: Secondary National Offer Day
- generic [ref=f4e59]: Mon, 1 March 2027
- generic [ref=f4e60]:
- generic [ref=f4e61]: Primary · Offer Day
- generic [ref=f4e63]: 288days
- generic [ref=f4e64]: Primary National Offer Day
- generic [ref=f4e65]: Fri, 16 April 2027
- generic [ref=f4e66]:
- generic [ref=f4e67]: Start exploring
- generic [ref=f4e68]:
- link "Top-rated primary schools" [ref=f4e69] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f4e71] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f4e73] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f4e75]:
- generic [ref=f4e76]:
- heading "What you'll see on every school" [level=2] [ref=f4e77]
- generic [ref=f4e78]: Primary or secondary — the page adapts to the phase
- generic [ref=f4e79]:
- generic [ref=f4e80]:
- generic [ref=f4e81]:
- generic [ref=f4e82]:
- generic [ref=f4e83]:
- text: Primary · Year 6 ·
- strong [ref=f4e84]: Key Stage 2 SATs
- generic [ref=f4e85]:
- generic [ref=f4e86]:
- generic [ref=f4e87]: Reading
- generic [ref=f4e88]:
- generic [ref=f4e89]: Expected
- strong [ref=f4e90]: 96%
- generic [ref=f4e91]:
- generic: 75%
- generic [ref=f4e93]:
- generic [ref=f4e94]: Exceeding
- strong [ref=f4e95]: 73%
- generic [ref=f4e98]:
- generic [ref=f4e99]: Writing
- generic [ref=f4e100]:
- generic [ref=f4e101]: Expected
- strong [ref=f4e102]: 81%
- generic [ref=f4e103]:
- generic: 72%
- generic [ref=f4e105]:
- generic [ref=f4e106]: Exceeding
- strong [ref=f4e107]: 15%
- generic [ref=f4e110]:
- generic [ref=f4e111]: Maths
- generic [ref=f4e112]:
- generic [ref=f4e113]: Expected
- strong [ref=f4e114]: 85%
- generic [ref=f4e115]:
- generic: 74%
- generic [ref=f4e117]:
- generic [ref=f4e118]: Exceeding
- strong [ref=f4e119]: 47%
- generic [ref=f4e122]:
- generic [ref=f4e123]:
- text: Secondary · Year 11 ·
- strong [ref=f4e124]: GCSE Attainment 8
- generic [ref=f4e125]:
- generic [ref=f4e127]:
- generic [ref=f4e128]: This school
- generic [ref=f4e129]: National avg 50.2
- generic [ref=f4e133]:
- generic [ref=f4e134]: "62.4"
- generic [ref=f4e135]: +12.2 vs national
- generic [ref=f4e136]:
- generic [ref=f4e137]: Performance
- generic [ref=f4e138]: Results against the national average
- paragraph [ref=f4e139]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f4e140]:
- generic [ref=f4e142]:
- generic [ref=f4e145]: Latest Ofsted inspection
- generic [ref=f4e146]: OUTSTANDING
- generic [ref=f4e147]:
- text: Rated
- emphasis [ref=f4e148]: Outstanding
- text: at last inspection.
- generic [ref=f4e149]: Full inspection · March 2024
- generic [ref=f4e150]:
- generic [ref=f4e151]: Judgement
- generic [ref=f4e152]: Ofsted at a glance
- paragraph [ref=f4e153]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f4e154]:
- generic [ref=f4e156]:
- generic [ref=f4e157]:
- generic [ref=f4e158]: Metric
- generic [ref=f4e159]:
- text: Our Lady
- text: Queen of Heaven
- generic [ref=f4e160]:
- text: St Mary's
- text: Catholic Primary
- generic [ref=f4e161]:
- generic [ref=f4e162]: Reading, Writing & Maths
- generic [ref=f4e163]: 70%
- generic [ref=f4e164]: 64%
- generic [ref=f4e165]:
- generic [ref=f4e166]: Ofsted
- generic [ref=f4e167]: Outstanding
- generic [ref=f4e168]: Good
- generic [ref=f4e169]:
- generic [ref=f4e170]: Reading progress
- generic [ref=f4e171]: "+2.1"
- generic [ref=f4e172]: "+0.4"
- generic [ref=f4e173]: + pin up to 5 schools
- generic [ref=f4e174]:
- generic [ref=f4e175]: Compare
- generic [ref=f4e176]: Side-by-side shortlists
- paragraph [ref=f4e177]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f4e179]:
- generic [ref=f4e180]:
- generic [ref=f4e181]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f4e182]
- paragraph [ref=f4e183]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f4e184]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f4e185]:
- heading "Coverage at a glance" [level=3] [ref=f4e186]
- generic [ref=f4e187]:
- generic [ref=f4e188]: Schools covered
- generic [ref=f4e189]: 24,000+
- generic [ref=f4e190]:
- generic [ref=f4e191]: Local authorities
- generic [ref=f4e192]: "182"
- generic [ref=f4e193]:
- generic [ref=f4e194]: Phases
- generic [ref=f4e195]: Primary & Secondary
- generic [ref=f4e196]:
- generic [ref=f4e197]: Latest results year
- generic [ref=f4e198]: 2024/25
- generic [ref=f4e199]:
- generic [ref=f4e200]: Historical data
- generic [ref=f4e201]: 2016–2025
- generic [ref=f4e202]:
- generic [ref=f4e203]: Metrics per school
- generic [ref=f4e204]: 40+
- contentinfo [ref=f4e205]:
- generic [ref=f4e206]:
- generic [ref=f4e207]:
- generic [ref=f4e208]:
- heading "SchoolCompare" [level=3] [ref=f4e209]
- paragraph [ref=f4e210]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f4e211] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f4e212]:
- heading "Product" [level=4] [ref=f4e213]
- list [ref=f4e214]:
- listitem [ref=f4e215]:
- link "Search schools" [ref=f4e216] [cursor=pointer]:
- /url: /
- listitem [ref=f4e217]:
- link "Rankings" [ref=f4e218] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f4e219]:
- link "Compare shortlist" [ref=f4e220] [cursor=pointer]:
- /url: /compare
- listitem [ref=f4e221]:
- link "Admissions guide" [ref=f4e222] [cursor=pointer]:
- /url: /admissions
- generic [ref=f4e223]:
- heading "Resources" [level=4] [ref=f4e224]
- list [ref=f4e225]:
- listitem [ref=f4e226]:
- link "School Performance Tables" [ref=f4e227] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f4e228]:
- link "Ofsted reports" [ref=f4e229] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f4e231]: © 2026 SchoolCompare.co.uk
- alert [ref=f4e232]
@@ -0,0 +1,101 @@
- generic [ref=f4e1]:
- link "Skip to main content" [ref=f4e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e3]:
- generic [ref=f4e4]:
- link "SchoolCompare home" [ref=f4e5] [cursor=pointer]:
- /url: /
- img [ref=f4e7]
- generic [ref=f4e11]: SchoolCompare
- navigation "Main navigation" [ref=f4e12]:
- link "Search" [ref=f4e13] [cursor=pointer]:
- /url: /
- link "Compare" [ref=f4e14] [cursor=pointer]:
- /url: /compare
- link "Rankings" [ref=f4e15] [cursor=pointer]:
- /url: /rankings
- link "Admissions" [ref=f4e16] [cursor=pointer]:
- /url: /admissions
- main [ref=f4e17]:
- generic [ref=f4e18]:
- generic [ref=f4e26]:
- generic [ref=f4e28]:
- searchbox "School name or postcode" [active] [ref=f4e29]: Welland Primary
- button "Search" [ref=f4e30] [cursor=pointer]
- generic [ref=f4e233]:
- combobox [ref=f4e234] [cursor=pointer]:
- option "All Phases" [selected]
- option "Primary"
- button "Advanced" [ref=f4e235] [cursor=pointer]: Advanced
- button "Clear" [ref=f4e237] [cursor=pointer]
- generic [ref=f4e238]:
- generic [ref=f4e239]:
- heading "1 school found" [level=2] [ref=f4e240]
- combobox [ref=f4e242] [cursor=pointer]:
- 'option "Sort: Relevance" [selected]'
- option "Highest Reading, Writing & Maths %"
- option "Lowest Reading, Writing & Maths %"
- option "Name A–Z"
- generic [ref=f4e244]:
- text: "Search: Welland Primary"
- link "×" [ref=f4e245] [cursor=pointer]:
- /url: /
- generic [ref=f4e247]:
- generic [ref=f4e248]:
- generic [ref=f4e249]:
- link "Welland Primary School" [ref=f4e250] [cursor=pointer]:
- /url: /school/146678-welland-primary-school
- generic [ref=f4e251]: Good · 2023
- generic [ref=f4e252]:
- generic [ref=f4e253]: Primary
- generic [ref=f4e254]: Academy converter
- generic [ref=f4e255]: Ages 4–11
- generic [ref=f4e256]:
- generic [ref=f4e257]:
- strong [ref=f4e258]: 74%
- 'generic "Previous year: 76.0%" [ref=f4e259]':
- img "Trend down" [ref=f4e260]
- generic [ref=f4e262]: Reading, Writing & Maths
- generic [ref=f4e263]: +12 pts vs national
- generic [ref=f4e264]:
- strong [ref=f4e265]: "131"
- generic [ref=f4e266]: pupils
- generic [ref=f4e268]: Worcestershire
- generic [ref=f4e269]:
- link "View" [ref=f4e270] [cursor=pointer]:
- /url: /school/146678-welland-primary-school
- button "+ Compare" [ref=f4e271] [cursor=pointer]
- contentinfo [ref=f4e205]:
- generic [ref=f4e206]:
- generic [ref=f4e207]:
- generic [ref=f4e208]:
- heading "SchoolCompare" [level=3] [ref=f4e209]
- paragraph [ref=f4e210]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f4e211] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f4e212]:
- heading "Product" [level=4] [ref=f4e213]
- list [ref=f4e214]:
- listitem [ref=f4e215]:
- link "Search schools" [ref=f4e216] [cursor=pointer]:
- /url: /
- listitem [ref=f4e217]:
- link "Rankings" [ref=f4e218] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f4e219]:
- link "Compare shortlist" [ref=f4e220] [cursor=pointer]:
- /url: /compare
- listitem [ref=f4e221]:
- link "Admissions guide" [ref=f4e222] [cursor=pointer]:
- /url: /admissions
- generic [ref=f4e223]:
- heading "Resources" [level=4] [ref=f4e224]
- list [ref=f4e225]:
- listitem [ref=f4e226]:
- link "School Performance Tables" [ref=f4e227] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f4e228]:
- link "Ofsted reports" [ref=f4e229] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f4e231]: © 2026 SchoolCompare.co.uk
- alert [ref=f4e232]
@@ -0,0 +1,229 @@
- generic [active] [ref=f5e1]:
- link "Skip to main content" [ref=f5e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f5e3]:
- generic [ref=f5e4]:
- link "SchoolCompare home" [ref=f5e5] [cursor=pointer]:
- /url: /
- img [ref=f5e7]
- generic [ref=f5e11]: SchoolCompare
- navigation "Main navigation" [ref=f5e12]:
- link "Search" [ref=f5e13] [cursor=pointer]:
- /url: /
- link "Compare" [ref=f5e14] [cursor=pointer]:
- /url: /compare
- link "Rankings" [ref=f5e15] [cursor=pointer]:
- /url: /rankings
- link "Admissions" [ref=f5e16] [cursor=pointer]:
- /url: /admissions
- main [ref=f5e17]:
- generic [ref=f5e18]:
- generic [ref=f5e19]:
- generic [ref=f5e20]: Updated with 2026/2027 admissions results
- heading "Every school in England, compared." [level=1] [ref=f5e22]:
- text: Every school in England,
- emphasis [ref=f5e23]: compared.
- paragraph [ref=f5e24]:
- strong [ref=f5e25]: 24,000+ primary and secondary schools
- text: with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
- generic [ref=f5e28]:
- searchbox "School name or postcode" [ref=f5e29]
- button "Search" [ref=f5e30] [cursor=pointer]
- button "Schools near me" [ref=f5e33] [cursor=pointer]:
- img [ref=f5e34]
- text: Schools near me
- generic [ref=f5e37]:
- generic [ref=f5e38]:
- generic [ref=f5e39]: Key admissions deadlines
- link "Full admissions guide →" [ref=f5e40] [cursor=pointer]:
- /url: /admissions
- generic [ref=f5e41]:
- generic [ref=f5e42]:
- generic [ref=f5e43]: Secondary · Deadline
- generic [ref=f5e45]: 121days
- generic [ref=f5e46]: Secondary applications close
- generic [ref=f5e47]: Sat, 31 October 2026
- generic [ref=f5e48]:
- generic [ref=f5e49]: Primary · Deadline
- generic [ref=f5e51]: 197days
- generic [ref=f5e52]: Primary applications close
- generic [ref=f5e53]: Fri, 15 January 2027
- generic [ref=f5e54]:
- generic [ref=f5e55]: Secondary · Offer Day
- generic [ref=f5e57]: 242days
- generic [ref=f5e58]: Secondary National Offer Day
- generic [ref=f5e59]: Mon, 1 March 2027
- generic [ref=f5e60]:
- generic [ref=f5e61]: Primary · Offer Day
- generic [ref=f5e63]: 288days
- generic [ref=f5e64]: Primary National Offer Day
- generic [ref=f5e65]: Fri, 16 April 2027
- generic [ref=f5e66]:
- generic [ref=f5e67]: Start exploring
- generic [ref=f5e68]:
- link "Top-rated primary schools" [ref=f5e69] [cursor=pointer]:
- /url: /rankings
- text: Top-rated primary schools
- link "Top-rated secondary schools" [ref=f5e71] [cursor=pointer]:
- /url: /rankings
- text: Top-rated secondary schools
- link "Start a comparison" [ref=f5e73] [cursor=pointer]:
- /url: /compare
- text: Start a comparison
- generic [ref=f5e75]:
- generic [ref=f5e76]:
- heading "What you'll see on every school" [level=2] [ref=f5e77]
- generic [ref=f5e78]: Primary or secondary — the page adapts to the phase
- generic [ref=f5e79]:
- generic [ref=f5e80]:
- generic [ref=f5e81]:
- generic [ref=f5e82]:
- generic [ref=f5e83]:
- text: Primary · Year 6 ·
- strong [ref=f5e84]: Key Stage 2 SATs
- generic [ref=f5e85]:
- generic [ref=f5e86]:
- generic [ref=f5e87]: Reading
- generic [ref=f5e88]:
- generic [ref=f5e89]: Expected
- strong [ref=f5e90]: 96%
- generic [ref=f5e91]:
- generic: 75%
- generic [ref=f5e93]:
- generic [ref=f5e94]: Exceeding
- strong [ref=f5e95]: 73%
- generic [ref=f5e98]:
- generic [ref=f5e99]: Writing
- generic [ref=f5e100]:
- generic [ref=f5e101]: Expected
- strong [ref=f5e102]: 81%
- generic [ref=f5e103]:
- generic: 72%
- generic [ref=f5e105]:
- generic [ref=f5e106]: Exceeding
- strong [ref=f5e107]: 15%
- generic [ref=f5e110]:
- generic [ref=f5e111]: Maths
- generic [ref=f5e112]:
- generic [ref=f5e113]: Expected
- strong [ref=f5e114]: 85%
- generic [ref=f5e115]:
- generic: 74%
- generic [ref=f5e117]:
- generic [ref=f5e118]: Exceeding
- strong [ref=f5e119]: 47%
- generic [ref=f5e122]:
- generic [ref=f5e123]:
- text: Secondary · Year 11 ·
- strong [ref=f5e124]: GCSE Attainment 8
- generic [ref=f5e125]:
- generic [ref=f5e127]:
- generic [ref=f5e128]: This school
- generic [ref=f5e129]: National avg 50.2
- generic [ref=f5e133]:
- generic [ref=f5e134]: "62.4"
- generic [ref=f5e135]: +12.2 vs national
- generic [ref=f5e136]:
- generic [ref=f5e137]: Performance
- generic [ref=f5e138]: Results against the national average
- paragraph [ref=f5e139]: For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
- generic [ref=f5e140]:
- generic [ref=f5e142]:
- generic [ref=f5e145]: Latest Ofsted inspection
- generic [ref=f5e146]: OUTSTANDING
- generic [ref=f5e147]:
- text: Rated
- emphasis [ref=f5e148]: Outstanding
- text: at last inspection.
- generic [ref=f5e149]: Full inspection · March 2024
- generic [ref=f5e150]:
- generic [ref=f5e151]: Judgement
- generic [ref=f5e152]: Ofsted at a glance
- paragraph [ref=f5e153]: Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
- generic [ref=f5e154]:
- generic [ref=f5e156]:
- generic [ref=f5e157]:
- generic [ref=f5e158]: Metric
- generic [ref=f5e159]:
- text: Our Lady
- text: Queen of Heaven
- generic [ref=f5e160]:
- text: St Mary's
- text: Catholic Primary
- generic [ref=f5e161]:
- generic [ref=f5e162]: Reading, Writing & Maths
- generic [ref=f5e163]: 70%
- generic [ref=f5e164]: 64%
- generic [ref=f5e165]:
- generic [ref=f5e166]: Ofsted
- generic [ref=f5e167]: Outstanding
- generic [ref=f5e168]: Good
- generic [ref=f5e169]:
- generic [ref=f5e170]: Reading progress
- generic [ref=f5e171]: "+2.1"
- generic [ref=f5e172]: "+0.4"
- generic [ref=f5e173]: + pin up to 5 schools
- generic [ref=f5e174]:
- generic [ref=f5e175]: Compare
- generic [ref=f5e176]: Side-by-side shortlists
- paragraph [ref=f5e177]: Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
- generic [ref=f5e179]:
- generic [ref=f5e180]:
- generic [ref=f5e181]: About school data
- heading "Making UK school performance data actually readable" [level=2] [ref=f5e182]
- paragraph [ref=f5e183]: School performance data in England is rich but fragmented. The Department for Education publishes Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and demographics — each in its own table, each with its own jargon.
- paragraph [ref=f5e184]: SchoolCompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a 40-page inspection report.
- generic [ref=f5e185]:
- heading "Coverage at a glance" [level=3] [ref=f5e186]
- generic [ref=f5e187]:
- generic [ref=f5e188]: Schools covered
- generic [ref=f5e189]: 24,000+
- generic [ref=f5e190]:
- generic [ref=f5e191]: Local authorities
- generic [ref=f5e192]: "182"
- generic [ref=f5e193]:
- generic [ref=f5e194]: Phases
- generic [ref=f5e195]: Primary & Secondary
- generic [ref=f5e196]:
- generic [ref=f5e197]: Latest results year
- generic [ref=f5e198]: 2024/25
- generic [ref=f5e199]:
- generic [ref=f5e200]: Historical data
- generic [ref=f5e201]: 2016–2025
- generic [ref=f5e202]:
- generic [ref=f5e203]: Metrics per school
- generic [ref=f5e204]: 40+
- contentinfo [ref=f5e205]:
- generic [ref=f5e206]:
- generic [ref=f5e207]:
- generic [ref=f5e208]:
- heading "SchoolCompare" [level=3] [ref=f5e209]
- paragraph [ref=f5e210]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f5e211] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f5e212]:
- heading "Product" [level=4] [ref=f5e213]
- list [ref=f5e214]:
- listitem [ref=f5e215]:
- link "Search schools" [ref=f5e216] [cursor=pointer]:
- /url: /
- listitem [ref=f5e217]:
- link "Rankings" [ref=f5e218] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f5e219]:
- link "Compare shortlist" [ref=f5e220] [cursor=pointer]:
- /url: /compare
- listitem [ref=f5e221]:
- link "Admissions guide" [ref=f5e222] [cursor=pointer]:
- /url: /admissions
- generic [ref=f5e223]:
- heading "Resources" [level=4] [ref=f5e224]
- list [ref=f5e225]:
- listitem [ref=f5e226]:
- link "School Performance Tables" [ref=f5e227] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f5e228]:
- link "Ofsted reports" [ref=f5e229] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f5e231]: © 2026 SchoolCompare.co.uk
- alert [ref=f5e232]
@@ -0,0 +1,214 @@
- generic [active] [ref=f6e1]:
- link "Skip to main content" [ref=f6e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e3]:
- link "SchoolCompare home" [ref=f6e5] [cursor=pointer]:
- /url: /
- img [ref=f6e7]
- generic [ref=f6e11]: SchoolCompare
- navigation "Main navigation" [ref=f6e12]:
- link "Search" [ref=f6e13] [cursor=pointer]:
- /url: /
- img [ref=f6e15]
- generic [ref=f6e18]: Search
- link "Compare" [ref=f6e19] [cursor=pointer]:
- /url: /compare
- img [ref=f6e21]
- generic [ref=f6e24]: Compare
- link "Rankings" [ref=f6e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f6e27]
- generic [ref=f6e28]: Rankings
- link "Admissions" [ref=f6e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f6e31]
- generic [ref=f6e34]: Admissions
- main [ref=f6e35]:
- generic [ref=f6e36]:
- button "Back" [ref=f6e37] [cursor=pointer]:
- generic [ref=f6e38]: ←
- text: Back
- generic [ref=f6e39]:
- generic [ref=f6e40]:
- generic [ref=f6e41]:
- img [ref=f6e45] [cursor=pointer]
- generic [ref=f6e48]:
- link "Leaflet" [ref=f6e49] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f6e50]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f6e54] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f6e55] [cursor=pointer]:
- generic [ref=f6e56]:
- img [ref=f6e57]
- text: Open full map
- generic [ref=f6e59]:
- generic [ref=f6e60]:
- heading "The Castle School" [level=1] [ref=f6e61]
- generic [ref=f6e62]:
- generic [ref=f6e63]: Academy converter
- generic [ref=f6e64]: Ages 11–19
- paragraph [ref=f6e65]:
- text: Wellington Road, Taunton, TA1 5AU ·
- button "View on map ↗" [ref=f6e66] [cursor=pointer]
- generic [ref=f6e67]:
- generic [ref=f6e68]:
- strong [ref=f6e69]: "Headteacher:"
- text: Mr James Lamb
- link "School website ↗" [ref=f6e71] [cursor=pointer]:
- /url: http://www.castleschool.co.uk/
- generic [ref=f6e72]:
- strong [ref=f6e73]: "Pupils:"
- text: "1,202 (capacity: 1188)"
- generic [ref=f6e74]:
- text: Part of
- strong [ref=f6e75]: BLACKDOWN EDUCATION PARTNERSHIP
- button "+ Add to Compare" [ref=f6e77] [cursor=pointer]
- navigation "Page sections" [ref=f6e78]:
- generic [ref=f6e79]:
- button "Back to top" [ref=f6e80] [cursor=pointer]: ↑ Top
- link "Ofsted" [ref=f6e82] [cursor=pointer]:
- /url: "#ofsted"
- link "GCSEs" [ref=f6e83] [cursor=pointer]:
- /url: "#gcse"
- link "Admissions" [ref=f6e84] [cursor=pointer]:
- /url: "#admissions"
- link "History" [ref=f6e85] [cursor=pointer]:
- /url: "#history"
- link "Wellbeing" [ref=f6e86] [cursor=pointer]:
- /url: "#wellbeing"
- generic [ref=f6e87]:
- heading "Ofsted Rating Inspected 3 October 2023 Ofsted reports ↗" [level=2] [ref=f6e88]:
- text: Ofsted Rating
- generic [ref=f6e89]: Inspected 3 October 2023
- link "Ofsted reports ↗" [ref=f6e90] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/136916
- generic [ref=f6e92]: Outstanding
- paragraph [ref=f6e93]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
- paragraph [ref=f6e94]:
- text: Rated
- strong [ref=f6e95]: Outstanding
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f6e96]:
- heading "GCSE Results (2024/25)" [level=2] [ref=f6e97]
- paragraph [ref=f6e98]: GCSE results for Year 11 pupils. National averages shown for comparison.
- generic [ref=f6e99]: Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
- generic [ref=f6e100]:
- generic [ref=f6e101]:
- generic [ref=f6e102]: Attainment 8 score
- generic [ref=f6e103]: "53.4"
- generic [ref=f6e104]:
- generic [ref=f6e105]: English & Maths Grade 5+
- generic [ref=f6e106]: 57.7%
- generic [ref=f6e107]:
- generic [ref=f6e108]: English & Maths Grade 4+
- generic [ref=f6e109]: 79.5%
- generic [ref=f6e110]:
- generic [ref=f6e111]: Attainment 8 — school vs national
- generic [ref=f6e114]:
- generic [ref=f6e115]: "0"
- generic [ref=f6e116]: "20"
- generic [ref=f6e117]: "40"
- generic [ref=f6e118]: "60"
- generic [ref=f6e119]: "80"
- heading "English Baccalaureate (EBacc)" [level=3] [ref=f6e120]
- generic [ref=f6e121]:
- generic [ref=f6e122]:
- generic [ref=f6e123]: Pupils entered for EBacc
- generic [ref=f6e124]: 30.1%
- generic [ref=f6e125]:
- generic [ref=f6e126]: EBacc Grade 4+
- generic [ref=f6e127]: 25.1%
- generic [ref=f6e128]:
- generic [ref=f6e129]: EBacc Grade 5+
- generic [ref=f6e130]: 20.5%
- generic [ref=f6e131]:
- generic [ref=f6e132]: EBacc average point score
- generic [ref=f6e133]: "4.72"
- generic [ref=f6e134]:
- heading "Admissions" [level=2] [ref=f6e135]
- generic [ref=f6e136]:
- generic [ref=f6e137]:
- generic [ref=f6e138]: Year 7 places offered
- generic [ref=f6e139]: "237"
- generic [ref=f6e140]:
- generic [ref=f6e141]: Total applications
- generic [ref=f6e142]: "707"
- generic [ref=f6e143]:
- generic [ref=f6e144]: 1st preference applications
- generic [ref=f6e145]: "336"
- generic [ref=f6e146]:
- generic [ref=f6e147]: Families who got their first choice
- generic [ref=f6e148]: 69.6%
- generic [ref=f6e149]: ⚠ Applications exceeded places last year
- paragraph [ref=f6e150]: Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
- generic [ref=f6e151]:
- heading "Historical Results" [level=2] [ref=f6e152]
- heading "Results Over Time" [level=3] [ref=f6e153]
- generic [ref=f6e155]:
- generic [ref=f6e156]:
- generic [ref=f6e157]: GCSE results · Year 11
- tablist [ref=f6e158]:
- tab [selected] [ref=f6e159] [cursor=pointer]: Attainment 8
- tab [ref=f6e160] [cursor=pointer]: English & Maths grade 4+
- tab [ref=f6e161] [cursor=pointer]: Progress 8
- img [ref=f6e163]
- group [ref=f6e164]:
- generic "▶ View raw year-by-year data" [ref=f6e165] [cursor=pointer]
- generic [ref=f6e166]:
- heading "Wellbeing & Context" [level=2] [ref=f6e167]
- heading "Special Educational Needs (SEN)" [level=3] [ref=f6e168]
- generic [ref=f6e169]:
- generic [ref=f6e170]:
- generic [ref=f6e171]: SEN support
- generic [ref=f6e172]: 5.4%
- generic [ref=f6e173]: Without an EHCP
- generic [ref=f6e174]:
- generic [ref=f6e175]: Pupils with EHCP
- generic [ref=f6e176]: 3.8%
- generic [ref=f6e177]: Education, Health and Care Plan
- generic [ref=f6e178]:
- generic [ref=f6e179]: Total pupils
- generic [ref=f6e180]: 1,207
- 'img "Gender split: 52% girls, 48% boys" [ref=f6e181]'
- generic [ref=f6e184]:
- generic [ref=f6e185]: 52% girls
- text: ·
- generic [ref=f6e186]: 48% boys
- contentinfo [ref=f6e187]:
- generic [ref=f6e188]:
- generic [ref=f6e189]:
- generic [ref=f6e190]:
- heading "SchoolCompare" [level=3] [ref=f6e191]
- paragraph [ref=f6e192]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f6e193] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f6e194]:
- heading "Product" [level=4] [ref=f6e195]
- list [ref=f6e196]:
- listitem [ref=f6e197]:
- link "Search schools" [ref=f6e198] [cursor=pointer]:
- /url: /
- listitem [ref=f6e199]:
- link "Rankings" [ref=f6e200] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f6e201]:
- link "Compare shortlist" [ref=f6e202] [cursor=pointer]:
- /url: /compare
- listitem [ref=f6e203]:
- link "Admissions guide" [ref=f6e204] [cursor=pointer]:
- /url: /admissions
- generic [ref=f6e205]:
- heading "Resources" [level=4] [ref=f6e206]
- list [ref=f6e207]:
- listitem [ref=f6e208]:
- link "School Performance Tables" [ref=f6e209] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f6e210]:
- link "Ofsted reports" [ref=f6e211] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f6e213]: © 2026 SchoolCompare.co.uk
- alert [ref=f6e214]
@@ -0,0 +1,226 @@
- generic [ref=f6e1]:
- link "Skip to main content" [ref=f6e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e3]:
- link "SchoolCompare home" [ref=f6e5] [cursor=pointer]:
- /url: /
- img [ref=f6e7]
- generic [ref=f6e11]: SchoolCompare
- navigation "Main navigation" [ref=f6e12]:
- link "Search" [ref=f6e13] [cursor=pointer]:
- /url: /
- img [ref=f6e15]
- generic [ref=f6e18]: Search
- link "1 selected Compare" [ref=f6e227] [cursor=pointer]:
- /url: /compare
- generic [ref=f6e20]:
- img [ref=f6e21]
- generic "1 selected" [ref=f6e228]: "1"
- generic [ref=f6e24]: Compare
- link "Rankings" [ref=f6e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f6e27]
- generic [ref=f6e28]: Rankings
- link "Admissions" [ref=f6e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f6e31]
- generic [ref=f6e34]: Admissions
- main [ref=f6e35]:
- generic [ref=f6e36]:
- button "Back" [ref=f6e37] [cursor=pointer]:
- generic [ref=f6e38]: ←
- text: Back
- generic [ref=f6e39]:
- generic [ref=f6e40]:
- generic [ref=f6e41]:
- img [ref=f6e45] [cursor=pointer]
- generic [ref=f6e48]:
- link "Leaflet" [ref=f6e49] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f6e50]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f6e54] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f6e55] [cursor=pointer]:
- generic [ref=f6e56]:
- img [ref=f6e57]
- text: Open full map
- generic [ref=f6e59]:
- generic [ref=f6e60]:
- heading "The Castle School" [level=1] [ref=f6e61]
- generic [ref=f6e62]:
- generic [ref=f6e63]: Academy converter
- generic [ref=f6e64]: Ages 11–19
- paragraph [ref=f6e65]:
- text: Wellington Road, Taunton, TA1 5AU ·
- button "View on map ↗" [ref=f6e66] [cursor=pointer]
- generic [ref=f6e67]:
- generic [ref=f6e68]:
- strong [ref=f6e69]: "Headteacher:"
- text: Mr James Lamb
- link "School website ↗" [ref=f6e71] [cursor=pointer]:
- /url: http://www.castleschool.co.uk/
- generic [ref=f6e72]:
- strong [ref=f6e73]: "Pupils:"
- text: "1,202 (capacity: 1188)"
- generic [ref=f6e74]:
- text: Part of
- strong [ref=f6e75]: BLACKDOWN EDUCATION PARTNERSHIP
- button "✓ In Comparison" [active] [ref=f6e229] [cursor=pointer]
- navigation "Page sections" [ref=f6e78]:
- generic [ref=f6e79]:
- button "Back to top" [ref=f6e80] [cursor=pointer]: ↑ Top
- link "Ofsted" [ref=f6e82] [cursor=pointer]:
- /url: "#ofsted"
- link "GCSEs" [ref=f6e83] [cursor=pointer]:
- /url: "#gcse"
- link "Admissions" [ref=f6e84] [cursor=pointer]:
- /url: "#admissions"
- link "History" [ref=f6e85] [cursor=pointer]:
- /url: "#history"
- link "Wellbeing" [ref=f6e86] [cursor=pointer]:
- /url: "#wellbeing"
- generic [ref=f6e87]:
- heading "Ofsted Rating Inspected 3 October 2023 Ofsted reports ↗" [level=2] [ref=f6e88]:
- text: Ofsted Rating
- generic [ref=f6e89]: Inspected 3 October 2023
- link "Ofsted reports ↗" [ref=f6e90] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/136916
- generic [ref=f6e92]: Outstanding
- paragraph [ref=f6e93]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
- paragraph [ref=f6e94]:
- text: Rated
- strong [ref=f6e95]: Outstanding
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f6e96]:
- heading "GCSE Results (2024/25)" [level=2] [ref=f6e97]
- paragraph [ref=f6e98]: GCSE results for Year 11 pupils. National averages shown for comparison.
- generic [ref=f6e99]: Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
- generic [ref=f6e100]:
- generic [ref=f6e101]:
- generic [ref=f6e102]: Attainment 8 score
- generic [ref=f6e103]:
- text: "53.4"
- generic [ref=f6e215]: +14 pts
- generic [ref=f6e216]: "National avg: 39.1"
- generic [ref=f6e104]:
- generic [ref=f6e105]: English & Maths Grade 5+
- generic [ref=f6e106]:
- text: 57.7%
- generic [ref=f6e217]: +22 pts
- generic [ref=f6e218]: "National avg: 36%"
- generic [ref=f6e107]:
- generic [ref=f6e108]: English & Maths Grade 4+
- generic [ref=f6e109]:
- text: 79.5%
- generic [ref=f6e219]: +28 pts
- generic [ref=f6e220]: "National avg: 51%"
- generic [ref=f6e110]:
- generic [ref=f6e111]: Attainment 8 — school vs national
- generic [ref=f6e222]: Nat avg 39.1
- generic [ref=f6e114]:
- generic [ref=f6e115]: "0"
- generic [ref=f6e116]: "20"
- generic [ref=f6e117]: "40"
- generic [ref=f6e118]: "60"
- generic [ref=f6e119]: "80"
- heading "English Baccalaureate (EBacc)" [level=3] [ref=f6e120]
- generic [ref=f6e121]:
- generic [ref=f6e122]:
- generic [ref=f6e123]: Pupils entered for EBacc
- generic [ref=f6e124]: 30.1%
- generic [ref=f6e125]:
- generic [ref=f6e126]: EBacc Grade 4+
- generic [ref=f6e127]: 25.1%
- generic [ref=f6e128]:
- generic [ref=f6e129]: EBacc Grade 5+
- generic [ref=f6e130]: 20.5%
- generic [ref=f6e131]:
- generic [ref=f6e132]: EBacc average point score
- generic [ref=f6e133]: "4.72"
- generic [ref=f6e134]:
- heading "Admissions" [level=2] [ref=f6e135]
- generic [ref=f6e136]:
- generic [ref=f6e137]:
- generic [ref=f6e138]: Year 7 places offered
- generic [ref=f6e139]: "237"
- generic [ref=f6e140]:
- generic [ref=f6e141]: Total applications
- generic [ref=f6e142]: "707"
- generic [ref=f6e143]:
- generic [ref=f6e144]: 1st preference applications
- generic [ref=f6e145]: "336"
- generic [ref=f6e146]:
- generic [ref=f6e147]: Families who got their first choice
- generic [ref=f6e148]: 69.6%
- generic [ref=f6e149]: ⚠ Applications exceeded places last year
- paragraph [ref=f6e150]: Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
- generic [ref=f6e151]:
- heading "Historical Results" [level=2] [ref=f6e152]
- heading "Results Over Time" [level=3] [ref=f6e153]
- generic [ref=f6e155]:
- generic [ref=f6e156]:
- generic [ref=f6e157]: GCSE results · Year 11
- tablist "Select metric" [ref=f6e223]:
- tab "Attainment 8" [selected] [ref=f6e224] [cursor=pointer]
- tab "English & Maths grade 4+" [ref=f6e225] [cursor=pointer]
- tab "Progress 8" [ref=f6e226] [cursor=pointer]
- img [ref=f6e163]
- group [ref=f6e164]:
- generic "▶ View raw year-by-year data" [ref=f6e165] [cursor=pointer]
- generic [ref=f6e166]:
- heading "Wellbeing & Context" [level=2] [ref=f6e167]
- heading "Special Educational Needs (SEN)" [level=3] [ref=f6e168]
- generic [ref=f6e169]:
- generic [ref=f6e170]:
- generic [ref=f6e171]: SEN support
- generic [ref=f6e172]: 5.4%
- generic [ref=f6e173]: Without an EHCP
- generic [ref=f6e174]:
- generic [ref=f6e175]: Pupils with EHCP
- generic [ref=f6e176]: 3.8%
- generic [ref=f6e177]: Education, Health and Care Plan
- generic [ref=f6e178]:
- generic [ref=f6e179]: Total pupils
- generic [ref=f6e180]: 1,207
- 'img "Gender split: 52% girls, 48% boys" [ref=f6e181]'
- generic [ref=f6e184]:
- generic [ref=f6e185]: 52% girls
- text: ·
- generic [ref=f6e186]: 48% boys
- contentinfo [ref=f6e187]:
- generic [ref=f6e188]:
- generic [ref=f6e189]:
- generic [ref=f6e190]:
- heading "SchoolCompare" [level=3] [ref=f6e191]
- paragraph [ref=f6e192]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f6e193] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f6e194]:
- heading "Product" [level=4] [ref=f6e195]
- list [ref=f6e196]:
- listitem [ref=f6e197]:
- link "Search schools" [ref=f6e198] [cursor=pointer]:
- /url: /
- listitem [ref=f6e199]:
- link "Rankings" [ref=f6e200] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f6e201]:
- link "Compare shortlist" [ref=f6e202] [cursor=pointer]:
- /url: /compare
- listitem [ref=f6e203]:
- link "Admissions guide" [ref=f6e204] [cursor=pointer]:
- /url: /admissions
- generic [ref=f6e205]:
- heading "Resources" [level=4] [ref=f6e206]
- list [ref=f6e207]:
- listitem [ref=f6e208]:
- link "School Performance Tables" [ref=f6e209] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f6e210]:
- link "Ofsted reports" [ref=f6e211] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f6e213]: © 2026 SchoolCompare.co.uk
- alert [ref=f6e214]
@@ -0,0 +1,120 @@
- generic [active] [ref=f7e1]:
- link "Skip to main content" [ref=f7e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f7e3]:
- link "SchoolCompare home" [ref=f7e5] [cursor=pointer]:
- /url: /
- img [ref=f7e7]
- generic [ref=f7e11]: SchoolCompare
- navigation "Main navigation" [ref=f7e12]:
- link "Search" [ref=f7e13] [cursor=pointer]:
- /url: /
- img [ref=f7e15]
- generic [ref=f7e18]: Search
- link "1 selected Compare" [ref=f7e19] [cursor=pointer]:
- /url: /compare
- generic [ref=f7e20]:
- img [ref=f7e21]
- generic "1 selected" [ref=f7e24]: "1"
- generic [ref=f7e25]: Compare
- link "Rankings" [ref=f7e26] [cursor=pointer]:
- /url: /rankings
- img [ref=f7e28]
- generic [ref=f7e29]: Rankings
- link "Admissions" [ref=f7e30] [cursor=pointer]:
- /url: /admissions
- img [ref=f7e32]
- generic [ref=f7e35]: Admissions
- main [ref=f7e36]:
- generic [ref=f7e37]:
- generic [ref=f7e39]:
- generic [ref=f7e40]:
- heading "Compare Schools" [level=1] [ref=f7e41]
- paragraph [ref=f7e42]: Comparing 1 school
- generic [ref=f7e43]:
- button "+ Add School" [ref=f7e44] [cursor=pointer]
- button "Share" [ref=f7e45] [cursor=pointer]:
- img [ref=f7e46]
- text: Share
- generic [ref=f7e49]:
- button "Primary (1)" [ref=f7e50] [cursor=pointer]
- button "Secondary (0)" [ref=f7e51] [cursor=pointer]
- generic [ref=f7e52]:
- generic [ref=f7e53]: "Select Metric:"
- combobox "Select Metric:" [ref=f7e54] [cursor=pointer]:
- option "Reading, Writing & Maths Combined %" [selected]
- option "Reading Expected %"
- option "Writing Expected %"
- option "Maths Expected %"
- option "GPS Expected %"
- option "Science Expected %"
- option "Reading, Writing & Maths Combined Higher %"
- option "Reading Higher %"
- option "Writing Higher %"
- option "Maths Higher %"
- option "GPS Higher %"
- option "Reading Progress"
- option "Writing Progress"
- option "Maths Progress"
- option "Reading Average Score"
- option "Maths Average Score"
- option "GPS Average Score"
- option "Reading, Writing & Maths Expected % (Boys)"
- option "Reading, Writing & Maths Expected % (Girls)"
- option "Reading, Writing & Maths Higher % (Boys)"
- option "Reading, Writing & Maths Higher % (Girls)"
- option "Reading, Writing & Maths Expected % (Disadvantaged)"
- option "Reading, Writing & Maths Expected % (Non-Disadvantaged)"
- option "Disadvantaged Gap"
- option "% Disadvantaged Pupils"
- option "% EAL Pupils"
- option "% SEN Support"
- option "% Pupil Stability"
- option "Reading Test Absence %"
- option "GPS Test Absence %"
- option "Maths Test Absence %"
- option "Writing Absence %"
- option "Science Absence %"
- paragraph [ref=f7e55]: "% meeting expected standard in reading, writing and maths"
- generic [ref=f7e58]:
- button "Remove The Castle School" [ref=f7e59] [cursor=pointer]: ×
- heading "The Castle School" [level=2] [ref=f7e60]:
- link "The Castle School" [ref=f7e61] [cursor=pointer]:
- /url: /school/136916-the-castle-school
- generic [ref=f7e62]:
- generic [ref=f7e63]: Somerset
- generic [ref=f7e64]: Academy converter
- contentinfo [ref=f7e76]:
- generic [ref=f7e77]:
- generic [ref=f7e78]:
- generic [ref=f7e79]:
- heading "SchoolCompare" [level=3] [ref=f7e80]
- paragraph [ref=f7e81]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f7e82] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f7e83]:
- heading "Product" [level=4] [ref=f7e84]
- list [ref=f7e85]:
- listitem [ref=f7e86]:
- link "Search schools" [ref=f7e87] [cursor=pointer]:
- /url: /
- listitem [ref=f7e88]:
- link "Rankings" [ref=f7e89] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f7e90]:
- link "Compare shortlist" [ref=f7e91] [cursor=pointer]:
- /url: /compare
- listitem [ref=f7e92]:
- link "Admissions guide" [ref=f7e93] [cursor=pointer]:
- /url: /admissions
- generic [ref=f7e94]:
- heading "Resources" [level=4] [ref=f7e95]
- list [ref=f7e96]:
- listitem [ref=f7e97]:
- link "School Performance Tables" [ref=f7e98] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f7e99]:
- link "Ofsted reports" [ref=f7e100] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f7e102]: © 2026 SchoolCompare.co.uk
- alert [ref=f7e103]
@@ -0,0 +1,270 @@
- generic [active] [ref=f8e1]:
- link "Skip to main content" [ref=f8e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e3]:
- link "SchoolCompare home" [ref=f8e5] [cursor=pointer]:
- /url: /
- img [ref=f8e7]
- generic [ref=f8e11]: SchoolCompare
- navigation "Main navigation" [ref=f8e12]:
- link "Search" [ref=f8e13] [cursor=pointer]:
- /url: /
- img [ref=f8e15]
- generic [ref=f8e18]: Search
- link "1 selected Compare" [ref=f8e19] [cursor=pointer]:
- /url: /compare
- generic [ref=f8e20]:
- img [ref=f8e21]
- generic "1 selected" [ref=f8e24]: "1"
- generic [ref=f8e25]: Compare
- link "Rankings" [ref=f8e26] [cursor=pointer]:
- /url: /rankings
- img [ref=f8e28]
- generic [ref=f8e29]: Rankings
- link "Admissions" [ref=f8e30] [cursor=pointer]:
- /url: /admissions
- img [ref=f8e32]
- generic [ref=f8e35]: Admissions
- main [ref=f8e36]:
- generic [ref=f8e37]:
- button "Back" [ref=f8e38] [cursor=pointer]:
- generic [ref=f8e39]: ←
- text: Back
- generic [ref=f8e40]:
- generic [ref=f8e41]:
- generic [ref=f8e42]:
- img [ref=f8e46] [cursor=pointer]
- generic [ref=f8e49]:
- link "Leaflet" [ref=f8e50] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f8e51]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f8e55] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f8e56] [cursor=pointer]:
- generic [ref=f8e57]:
- img [ref=f8e58]
- text: Open full map
- generic [ref=f8e60]:
- generic [ref=f8e61]:
- heading "Welland Primary School" [level=1] [ref=f8e62]
- generic [ref=f8e63]:
- generic [ref=f8e64]: Worcestershire
- generic [ref=f8e65]: Academy converter
- paragraph [ref=f8e66]:
- text: Marlbank Road, Welland, Malvern, WR13 6NE ·
- button "View on map ↗" [ref=f8e67] [cursor=pointer]
- button "+ Add to Compare" [ref=f8e69] [cursor=pointer]
- navigation "Page sections" [ref=f8e70]:
- button "Back to top" [ref=f8e71] [cursor=pointer]:
- generic [ref=f8e72]: ↑
- button "Section Ofsted" [ref=f8e73] [cursor=pointer]:
- generic [ref=f8e74]:
- generic [ref=f8e75]: Section
- generic [ref=f8e76]: Ofsted
- generic [ref=f8e77]: ▾
- generic [ref=f8e78]:
- heading "Ofsted Rating Inspected 6 June 2023 Ofsted reports ↗" [level=2] [ref=f8e79]:
- text: Ofsted Rating
- generic [ref=f8e80]: Inspected 6 June 2023
- link "Ofsted reports ↗" [ref=f8e81] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/146678
- generic [ref=f8e83]: Good
- paragraph [ref=f8e84]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
- paragraph [ref=f8e85]:
- text: Rated
- strong [ref=f8e86]: Good
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f8e87]:
- heading "SATs Results (2024/25)" [level=2] [ref=f8e88]
- paragraph [ref=f8e89]: End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.
- generic [ref=f8e90]:
- generic [ref=f8e91]:
- generic [ref=f8e92]: Reading, Writing & Maths combined
- generic [ref=f8e93]:
- text: 74.0%
- generic [ref=f8e94]: +12 pts
- generic [ref=f8e95]: "National avg: 62%"
- generic [ref=f8e96]:
- generic [ref=f8e97]: Exceeding expected level (Reading, Writing & Maths)
- generic [ref=f8e98]:
- text: 11.0%
- generic [ref=f8e99]: +3 pts
- generic [ref=f8e100]: "National avg: 8%"
- generic [ref=f8e101]:
- generic [ref=f8e102]: "?"
- generic [ref=f8e103]:
- generic [ref=f8e104]:
- text: Why is combined lower? A pupil is only counted if they met the bar in
- strong [ref=f8e105]: all three
- text: subjects. Some passed reading but not writing; some passed writing but not maths.
- generic [ref=f8e106]:
- generic [ref=f8e107]:
- text: Reading
- strong [ref=f8e108]: 74%
- generic [ref=f8e109]: ·
- generic [ref=f8e110]:
- text: Writing
- strong [ref=f8e111]: 74%
- generic [ref=f8e112]: ·
- generic [ref=f8e113]:
- text: Maths
- strong [ref=f8e114]: 81%
- generic [ref=f8e115]: →
- generic [ref=f8e116]:
- text: All three
- strong [ref=f8e117]: 74%
- generic [ref=f8e118]:
- generic [ref=f8e119]:
- generic [ref=f8e120]:
- generic [ref=f8e121]: Reading
- generic [ref=f8e122]:
- generic:
- generic: 75%
- generic [ref=f8e123]:
- generic [ref=f8e125]:
- generic [ref=f8e126]: Expected
- generic [ref=f8e127]: 74%
- generic [ref=f8e131]:
- generic [ref=f8e132]: Exceeding
- generic [ref=f8e133]: 48%
- generic [ref=f8e136]:
- generic [ref=f8e138]: 0%
- generic [ref=f8e140]: 25%
- generic [ref=f8e142]: 50%
- generic [ref=f8e144]: 75%
- generic [ref=f8e146]: 100%
- generic [ref=f8e147]:
- generic [ref=f8e148]: Writing
- generic [ref=f8e149]:
- generic:
- generic: 72%
- generic [ref=f8e150]:
- generic [ref=f8e152]:
- generic [ref=f8e153]: Expected
- generic [ref=f8e154]: 74%
- generic [ref=f8e158]:
- generic [ref=f8e159]: Exceeding
- generic [ref=f8e160]: 11%
- generic [ref=f8e163]:
- generic [ref=f8e165]: 0%
- generic [ref=f8e167]: 25%
- generic [ref=f8e169]: 50%
- generic [ref=f8e171]: 75%
- generic [ref=f8e173]: 100%
- generic [ref=f8e174]:
- generic [ref=f8e175]: Maths
- generic [ref=f8e176]:
- generic:
- generic: 74%
- generic [ref=f8e177]:
- generic [ref=f8e179]:
- generic [ref=f8e180]: Expected
- generic [ref=f8e181]: 81%
- generic [ref=f8e185]:
- generic [ref=f8e186]: Exceeding
- generic [ref=f8e187]: 33%
- generic [ref=f8e190]:
- generic [ref=f8e192]: 0%
- generic [ref=f8e194]: 25%
- generic [ref=f8e196]: 50%
- generic [ref=f8e198]: 75%
- generic [ref=f8e200]: 100%
- generic [ref=f8e201]:
- generic [ref=f8e202]: Expected standard
- generic [ref=f8e204]: Exceeding / high score
- generic [ref=f8e206]: National average
- generic [ref=f8e208]:
- generic [ref=f8e209]:
- heading "Admissions" [level=2] [ref=f8e210]
- group "Admissions view" [ref=f8e211]:
- button "This year" [pressed] [ref=f8e212] [cursor=pointer]
- button "8-year trend" [ref=f8e213] [cursor=pointer]
- generic [ref=f8e216]:
- generic [ref=f8e217]:
- definition [ref=f8e218]: "21"
- term [ref=f8e219]: Places offered
- generic [ref=f8e220]:
- definition [ref=f8e221]: "19"
- term [ref=f8e222]: Wanted it first
- generic [ref=f8e223]:
- definition [ref=f8e224]:
- text: "19"
- generic [ref=f8e225]: of 19 · 100.0%
- term [ref=f8e226]: Got their first choice
- generic [ref=f8e227]:
- definition [ref=f8e228]: "45"
- term [ref=f8e229]: Applied in total
- generic [ref=f8e230]:
- heading "Pupils & Inclusion" [level=2] [ref=f8e231]
- generic [ref=f8e232]:
- generic [ref=f8e233]:
- generic [ref=f8e234]: Eligible for pupil premium
- generic [ref=f8e235]: 26.0%
- generic [ref=f8e236]: Pupils from disadvantaged backgrounds
- generic [ref=f8e237]:
- generic [ref=f8e238]: English as an additional language
- generic [ref=f8e239]: 0.0%
- generic [ref=f8e240]:
- generic [ref=f8e241]: Pupils receiving SEN support
- generic [ref=f8e242]: 4.0%
- generic [ref=f8e243]:
- generic [ref=f8e244]: Boys and girls
- generic [ref=f8e245]:
- generic [ref=f8e246]: 48%
- generic [ref=f8e247]: girls
- generic [ref=f8e248]: ·
- generic [ref=f8e249]: 52%
- generic [ref=f8e250]: boys
- 'img "Gender split: 48% girls, 52% boys" [ref=f8e251]'
- generic [ref=f8e254]: 64 girls, 70 boys
- generic [ref=f8e255]:
- heading "Results Over Time" [level=2] [ref=f8e256]
- generic [ref=f8e258]:
- generic [ref=f8e259]: ↓ Reading, Writing & Maths peaked at 81% (2022/23), currently 74%
- generic [ref=f8e260]:
- generic [ref=f8e261]: KS2 SATs · Reading, Writing & Maths
- tablist "Select metric" [ref=f8e262]:
- tab "At expected level" [selected] [ref=f8e263] [cursor=pointer]
- tab "Above expected level" [ref=f8e264] [cursor=pointer]
- tab "Pupil progress" [ref=f8e265] [cursor=pointer]
- img [ref=f8e267]
- paragraph [ref=f8e268]: "* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19."
- group [ref=f8e269]:
- generic "▸ View raw year-by-year data" [ref=f8e270] [cursor=pointer]
- contentinfo [ref=f8e271]:
- generic [ref=f8e272]:
- generic [ref=f8e273]:
- generic [ref=f8e274]:
- heading "SchoolCompare" [level=3] [ref=f8e275]
- paragraph [ref=f8e276]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f8e277] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f8e278]:
- heading "Product" [level=4] [ref=f8e279]
- list [ref=f8e280]:
- listitem [ref=f8e281]:
- link "Search schools" [ref=f8e282] [cursor=pointer]:
- /url: /
- listitem [ref=f8e283]:
- link "Rankings" [ref=f8e284] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f8e285]:
- link "Compare shortlist" [ref=f8e286] [cursor=pointer]:
- /url: /compare
- listitem [ref=f8e287]:
- link "Admissions guide" [ref=f8e288] [cursor=pointer]:
- /url: /admissions
- generic [ref=f8e289]:
- heading "Resources" [level=4] [ref=f8e290]
- list [ref=f8e291]:
- listitem [ref=f8e292]:
- link "School Performance Tables" [ref=f8e293] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f8e294]:
- link "Ofsted reports" [ref=f8e295] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f8e297]: © 2026 SchoolCompare.co.uk
- alert [ref=f8e298]
@@ -0,0 +1,226 @@
- generic [active] [ref=f9e1]:
- link "Skip to main content" [ref=f9e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f9e3]:
- link "SchoolCompare home" [ref=f9e5] [cursor=pointer]:
- /url: /
- img [ref=f9e7]
- generic [ref=f9e11]: SchoolCompare
- navigation "Main navigation" [ref=f9e12]:
- link "Search" [ref=f9e13] [cursor=pointer]:
- /url: /
- img [ref=f9e15]
- generic [ref=f9e18]: Search
- link "1 selected Compare" [ref=f9e19] [cursor=pointer]:
- /url: /compare
- generic [ref=f9e20]:
- img [ref=f9e21]
- generic "1 selected" [ref=f9e24]: "1"
- generic [ref=f9e25]: Compare
- link "Rankings" [ref=f9e26] [cursor=pointer]:
- /url: /rankings
- img [ref=f9e28]
- generic [ref=f9e29]: Rankings
- link "Admissions" [ref=f9e30] [cursor=pointer]:
- /url: /admissions
- img [ref=f9e32]
- generic [ref=f9e35]: Admissions
- main [ref=f9e36]:
- generic [ref=f9e37]:
- button "Back" [ref=f9e38] [cursor=pointer]:
- generic [ref=f9e39]: ←
- text: Back
- generic [ref=f9e40]:
- generic [ref=f9e41]:
- generic [ref=f9e42]:
- img [ref=f9e46] [cursor=pointer]
- generic [ref=f9e49]:
- link "Leaflet" [ref=f9e50] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f9e51]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f9e55] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f9e56] [cursor=pointer]:
- generic [ref=f9e57]:
- img [ref=f9e58]
- text: Open full map
- generic [ref=f9e60]:
- generic [ref=f9e61]:
- heading "The Castle School" [level=1] [ref=f9e62]
- generic [ref=f9e63]:
- generic [ref=f9e64]: Academy converter
- generic [ref=f9e65]: Ages 11–19
- paragraph [ref=f9e66]:
- text: Wellington Road, Taunton, TA1 5AU ·
- button "View on map ↗" [ref=f9e67] [cursor=pointer]
- generic [ref=f9e68]:
- generic [ref=f9e69]:
- strong [ref=f9e70]: "Headteacher:"
- text: Mr James Lamb
- link "School website ↗" [ref=f9e72] [cursor=pointer]:
- /url: http://www.castleschool.co.uk/
- generic [ref=f9e73]:
- strong [ref=f9e74]: "Pupils:"
- text: "1,202 (capacity: 1188)"
- generic [ref=f9e75]:
- text: Part of
- strong [ref=f9e76]: BLACKDOWN EDUCATION PARTNERSHIP
- button "✓ In Comparison" [ref=f9e78] [cursor=pointer]
- navigation "Page sections" [ref=f9e79]:
- generic [ref=f9e80]:
- button "Back to top" [ref=f9e81] [cursor=pointer]: ↑ Top
- link "Ofsted" [ref=f9e83] [cursor=pointer]:
- /url: "#ofsted"
- link "GCSEs" [ref=f9e84] [cursor=pointer]:
- /url: "#gcse"
- link "Admissions" [ref=f9e85] [cursor=pointer]:
- /url: "#admissions"
- link "History" [ref=f9e86] [cursor=pointer]:
- /url: "#history"
- link "Wellbeing" [ref=f9e87] [cursor=pointer]:
- /url: "#wellbeing"
- generic [ref=f9e88]:
- heading "Ofsted Rating Inspected 3 October 2023 Ofsted reports ↗" [level=2] [ref=f9e89]:
- text: Ofsted Rating
- generic [ref=f9e90]: Inspected 3 October 2023
- link "Ofsted reports ↗" [ref=f9e91] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/136916
- generic [ref=f9e93]: Outstanding
- paragraph [ref=f9e94]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
- paragraph [ref=f9e95]:
- text: Rated
- strong [ref=f9e96]: Outstanding
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f9e97]:
- heading "GCSE Results (2024/25)" [level=2] [ref=f9e98]
- paragraph [ref=f9e99]: GCSE results for Year 11 pupils. National averages shown for comparison.
- generic [ref=f9e100]: Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
- generic [ref=f9e101]:
- generic [ref=f9e102]:
- generic [ref=f9e103]: Attainment 8 score
- generic [ref=f9e104]:
- text: "53.4"
- generic [ref=f9e105]: +14 pts
- generic [ref=f9e106]: "National avg: 39.1"
- generic [ref=f9e107]:
- generic [ref=f9e108]: English & Maths Grade 5+
- generic [ref=f9e109]:
- text: 57.7%
- generic [ref=f9e110]: +22 pts
- generic [ref=f9e111]: "National avg: 36%"
- generic [ref=f9e112]:
- generic [ref=f9e113]: English & Maths Grade 4+
- generic [ref=f9e114]:
- text: 79.5%
- generic [ref=f9e115]: +28 pts
- generic [ref=f9e116]: "National avg: 51%"
- generic [ref=f9e117]:
- generic [ref=f9e118]: Attainment 8 — school vs national
- generic [ref=f9e122]: Nat avg 39.1
- generic [ref=f9e123]:
- generic [ref=f9e124]: "0"
- generic [ref=f9e125]: "20"
- generic [ref=f9e126]: "40"
- generic [ref=f9e127]: "60"
- generic [ref=f9e128]: "80"
- heading "English Baccalaureate (EBacc)" [level=3] [ref=f9e129]
- generic [ref=f9e130]:
- generic [ref=f9e131]:
- generic [ref=f9e132]: Pupils entered for EBacc
- generic [ref=f9e133]: 30.1%
- generic [ref=f9e134]:
- generic [ref=f9e135]: EBacc Grade 4+
- generic [ref=f9e136]: 25.1%
- generic [ref=f9e137]:
- generic [ref=f9e138]: EBacc Grade 5+
- generic [ref=f9e139]: 20.5%
- generic [ref=f9e140]:
- generic [ref=f9e141]: EBacc average point score
- generic [ref=f9e142]: "4.72"
- generic [ref=f9e143]:
- heading "Admissions" [level=2] [ref=f9e144]
- generic [ref=f9e145]:
- generic [ref=f9e146]:
- generic [ref=f9e147]: Year 7 places offered
- generic [ref=f9e148]: "237"
- generic [ref=f9e149]:
- generic [ref=f9e150]: Total applications
- generic [ref=f9e151]: "707"
- generic [ref=f9e152]:
- generic [ref=f9e153]: 1st preference applications
- generic [ref=f9e154]: "336"
- generic [ref=f9e155]:
- generic [ref=f9e156]: Families who got their first choice
- generic [ref=f9e157]: 69.6%
- generic [ref=f9e158]: ⚠ Applications exceeded places last year
- paragraph [ref=f9e159]: Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
- generic [ref=f9e160]:
- heading "Historical Results" [level=2] [ref=f9e161]
- heading "Results Over Time" [level=3] [ref=f9e162]
- generic [ref=f9e164]:
- generic [ref=f9e165]:
- generic [ref=f9e166]: GCSE results · Year 11
- tablist "Select metric" [ref=f9e167]:
- tab "Attainment 8" [selected] [ref=f9e168] [cursor=pointer]
- tab "English & Maths grade 4+" [ref=f9e169] [cursor=pointer]
- tab "Progress 8" [ref=f9e170] [cursor=pointer]
- img [ref=f9e172]
- group [ref=f9e173]:
- generic "▶ View raw year-by-year data" [ref=f9e174] [cursor=pointer]
- generic [ref=f9e175]:
- heading "Wellbeing & Context" [level=2] [ref=f9e176]
- heading "Special Educational Needs (SEN)" [level=3] [ref=f9e177]
- generic [ref=f9e178]:
- generic [ref=f9e179]:
- generic [ref=f9e180]: SEN support
- generic [ref=f9e181]: 5.4%
- generic [ref=f9e182]: Without an EHCP
- generic [ref=f9e183]:
- generic [ref=f9e184]: Pupils with EHCP
- generic [ref=f9e185]: 3.8%
- generic [ref=f9e186]: Education, Health and Care Plan
- generic [ref=f9e187]:
- generic [ref=f9e188]: Total pupils
- generic [ref=f9e189]: 1,207
- 'img "Gender split: 52% girls, 48% boys" [ref=f9e190]'
- generic [ref=f9e193]:
- generic [ref=f9e194]: 52% girls
- text: ·
- generic [ref=f9e195]: 48% boys
- contentinfo [ref=f9e196]:
- generic [ref=f9e197]:
- generic [ref=f9e198]:
- generic [ref=f9e199]:
- heading "SchoolCompare" [level=3] [ref=f9e200]
- paragraph [ref=f9e201]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f9e202] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f9e203]:
- heading "Product" [level=4] [ref=f9e204]
- list [ref=f9e205]:
- listitem [ref=f9e206]:
- link "Search schools" [ref=f9e207] [cursor=pointer]:
- /url: /
- listitem [ref=f9e208]:
- link "Rankings" [ref=f9e209] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f9e210]:
- link "Compare shortlist" [ref=f9e211] [cursor=pointer]:
- /url: /compare
- listitem [ref=f9e212]:
- link "Admissions guide" [ref=f9e213] [cursor=pointer]:
- /url: /admissions
- generic [ref=f9e214]:
- heading "Resources" [level=4] [ref=f9e215]
- list [ref=f9e216]:
- listitem [ref=f9e217]:
- link "School Performance Tables" [ref=f9e218] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f9e219]:
- link "Ofsted reports" [ref=f9e220] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f9e222]: © 2026 SchoolCompare.co.uk
- alert [ref=f9e223]
@@ -0,0 +1,230 @@
- generic [active] [ref=f10e1]:
- link "Skip to main content" [ref=f10e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f10e3]:
- generic [ref=f10e4]:
- link "SchoolCompare home" [ref=f10e5] [cursor=pointer]:
- /url: /
- img [ref=f10e7]
- generic [ref=f10e11]: SchoolCompare
- navigation "Main navigation" [ref=f10e12]:
- link "Search" [ref=f10e13] [cursor=pointer]:
- /url: /
- link "Compare 1" [ref=f10e14] [cursor=pointer]:
- /url: /compare
- text: Compare
- generic [ref=f10e15]: "1"
- link "Rankings" [ref=f10e16] [cursor=pointer]:
- /url: /rankings
- link "Admissions" [ref=f10e17] [cursor=pointer]:
- /url: /admissions
- main [ref=f10e18]:
- generic [ref=f10e19]:
- button "Back" [ref=f10e20] [cursor=pointer]:
- generic [ref=f10e21]: ←
- text: Back
- generic [ref=f10e22]:
- generic [ref=f10e23]:
- generic [ref=f10e24]:
- img [ref=f10e28] [cursor=pointer]
- generic [ref=f10e31]:
- link "Leaflet" [ref=f10e32] [cursor=pointer]:
- /url: https://leafletjs.com
- img [ref=f10e33]
- text: Leaflet
- text: "| ©"
- link "OpenStreetMap" [ref=f10e37] [cursor=pointer]:
- /url: https://www.openstreetmap.org/copyright
- text: contributors
- button "Open full map" [ref=f10e38] [cursor=pointer]:
- generic [ref=f10e39]:
- img [ref=f10e40]
- text: Open full map
- generic [ref=f10e42]:
- generic [ref=f10e43]:
- heading "The Castle School" [level=1] [ref=f10e44]
- generic [ref=f10e45]:
- generic [ref=f10e46]: Academy converter
- generic [ref=f10e47]: Ages 11–19
- paragraph [ref=f10e48]:
- text: Wellington Road, Taunton, TA1 5AU ·
- button "View on map ↗" [ref=f10e49] [cursor=pointer]
- generic [ref=f10e50]:
- generic [ref=f10e51]:
- strong [ref=f10e52]: "Headteacher:"
- text: Mr James Lamb
- link "School website ↗" [ref=f10e54] [cursor=pointer]:
- /url: http://www.castleschool.co.uk/
- generic [ref=f10e55]:
- strong [ref=f10e56]: "Pupils:"
- text: "1,202 (capacity: 1188)"
- generic [ref=f10e57]:
- text: Part of
- strong [ref=f10e58]: BLACKDOWN EDUCATION PARTNERSHIP
- button "✓ In Comparison" [ref=f10e60] [cursor=pointer]
- navigation "Page sections" [ref=f10e61]:
- generic [ref=f10e62]:
- button "Back to top" [ref=f10e63] [cursor=pointer]: ↑ Top
- link "Ofsted" [ref=f10e65] [cursor=pointer]:
- /url: "#ofsted"
- link "GCSEs" [ref=f10e66] [cursor=pointer]:
- /url: "#gcse"
- link "Admissions" [ref=f10e67] [cursor=pointer]:
- /url: "#admissions"
- link "History" [ref=f10e68] [cursor=pointer]:
- /url: "#history"
- link "Wellbeing" [ref=f10e69] [cursor=pointer]:
- /url: "#wellbeing"
- generic [ref=f10e70]:
- heading "Ofsted Rating Inspected 3 October 2023 Ofsted reports ↗" [level=2] [ref=f10e71]:
- text: Ofsted Rating
- generic [ref=f10e72]: Inspected 3 October 2023
- link "Ofsted reports ↗" [ref=f10e73] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/136916
- generic [ref=f10e75]: Outstanding
- paragraph [ref=f10e76]: From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
- paragraph [ref=f10e77]:
- text: Rated
- strong [ref=f10e78]: Outstanding
- text: across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
- generic [ref=f10e79]:
- heading "GCSE Results (2024/25)" [level=2] [ref=f10e80]
- paragraph [ref=f10e81]: GCSE results for Year 11 pupils. National averages shown for comparison.
- generic [ref=f10e82]: Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
- generic [ref=f10e83]:
- generic [ref=f10e84]:
- generic [ref=f10e85]:
- text: Attainment 8 score
- img "Attainment 8" [ref=f10e87]: ⓘ
- generic [ref=f10e88]:
- text: "53.4"
- generic [ref=f10e89]: +14 pts
- generic [ref=f10e90]: "National avg: 39.1"
- generic [ref=f10e91]:
- generic [ref=f10e92]:
- text: English & Maths Grade 5+
- img "English & Maths — Grade 5+ (Strong Pass)" [ref=f10e94]: ⓘ
- generic [ref=f10e95]:
- text: 57.7%
- generic [ref=f10e96]: +22 pts
- generic [ref=f10e97]: "National avg: 36%"
- generic [ref=f10e98]:
- generic [ref=f10e99]:
- text: English & Maths Grade 4+
- img "English & Maths — Grade 4+ (Standard Pass)" [ref=f10e101]: ⓘ
- generic [ref=f10e102]:
- text: 79.5%
- generic [ref=f10e103]: +28 pts
- generic [ref=f10e104]: "National avg: 51%"
- generic [ref=f10e105]:
- generic [ref=f10e106]: Attainment 8 — school vs national
- generic [ref=f10e110]: Nat avg 39.1
- generic [ref=f10e111]:
- generic [ref=f10e112]: "0"
- generic [ref=f10e113]: "20"
- generic [ref=f10e114]: "40"
- generic [ref=f10e115]: "60"
- generic [ref=f10e116]: "80"
- heading "English Baccalaureate (EBacc) EBacc Entry" [level=3] [ref=f10e117]:
- text: English Baccalaureate (EBacc)
- img "EBacc Entry" [ref=f10e119]: ⓘ
- generic [ref=f10e120]:
- generic [ref=f10e121]:
- generic [ref=f10e122]: Pupils entered for EBacc
- generic [ref=f10e123]: 30.1%
- generic [ref=f10e124]:
- generic [ref=f10e125]: EBacc Grade 4+
- generic [ref=f10e126]: 25.1%
- generic [ref=f10e127]:
- generic [ref=f10e128]: EBacc Grade 5+
- generic [ref=f10e129]: 20.5%
- generic [ref=f10e130]:
- generic [ref=f10e131]: EBacc average point score
- generic [ref=f10e132]: "4.72"
- generic [ref=f10e133]:
- heading "Admissions" [level=2] [ref=f10e134]
- generic [ref=f10e135]:
- generic [ref=f10e136]:
- generic [ref=f10e137]: Year 7 places offered
- generic [ref=f10e138]: "237"
- generic [ref=f10e139]:
- generic [ref=f10e140]: Total applications
- generic [ref=f10e141]: "707"
- generic [ref=f10e142]:
- generic [ref=f10e143]: 1st preference applications
- generic [ref=f10e144]: "336"
- generic [ref=f10e145]:
- generic [ref=f10e146]: Families who got their first choice
- generic [ref=f10e147]: 69.6%
- generic [ref=f10e148]: ⚠ Applications exceeded places last year
- paragraph [ref=f10e149]: Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
- generic [ref=f10e150]:
- heading "Historical Results" [level=2] [ref=f10e151]
- heading "Results Over Time" [level=3] [ref=f10e152]
- img [ref=f10e156]
- group [ref=f10e157]:
- generic "▶ View raw year-by-year data" [ref=f10e158] [cursor=pointer]
- generic [ref=f10e159]:
- heading "Wellbeing & Context" [level=2] [ref=f10e160]
- heading "Special Educational Needs (SEN)" [level=3] [ref=f10e161]
- generic [ref=f10e162]:
- generic [ref=f10e163]:
- generic [ref=f10e164]:
- text: SEN support
- img "SEN Support" [ref=f10e166]: ⓘ
- generic [ref=f10e167]: 5.4%
- generic [ref=f10e168]: Without an EHCP
- generic [ref=f10e169]:
- generic [ref=f10e170]:
- text: Pupils with EHCP
- img "Education, Health and Care Plan (EHCP)" [ref=f10e172]: ⓘ
- generic [ref=f10e173]: 3.8%
- generic [ref=f10e174]: Education, Health and Care Plan
- generic [ref=f10e175]:
- generic [ref=f10e176]: Total pupils
- generic [ref=f10e177]: 1,207
- 'img "Gender split: 52% girls, 48% boys" [ref=f10e178]'
- generic [ref=f10e181]:
- generic [ref=f10e182]: 52% girls
- text: ·
- generic [ref=f10e183]: 48% boys
- generic [ref=f10e186]:
- generic [ref=f10e187]:
- generic [ref=f10e188]: "1"
- text: school selected
- button "Expand comparison panel" [ref=f10e189] [cursor=pointer]:
- img [ref=f10e190]
- contentinfo [ref=f10e192]:
- generic [ref=f10e193]:
- generic [ref=f10e194]:
- generic [ref=f10e195]:
- heading "SchoolCompare" [level=3] [ref=f10e196]
- paragraph [ref=f10e197]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f10e198] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f10e199]:
- heading "Product" [level=4] [ref=f10e200]
- list [ref=f10e201]:
- listitem [ref=f10e202]:
- link "Search schools" [ref=f10e203] [cursor=pointer]:
- /url: /
- listitem [ref=f10e204]:
- link "Rankings" [ref=f10e205] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f10e206]:
- link "Compare shortlist" [ref=f10e207] [cursor=pointer]:
- /url: /compare
- listitem [ref=f10e208]:
- link "Admissions guide" [ref=f10e209] [cursor=pointer]:
- /url: /admissions
- generic [ref=f10e210]:
- heading "Resources" [level=4] [ref=f10e211]
- list [ref=f10e212]:
- listitem [ref=f10e213]:
- link "School Performance Tables" [ref=f10e214] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f10e215]:
- link "Ofsted reports" [ref=f10e216] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f10e218]: © 2026 SchoolCompare.co.uk
- alert [ref=f10e219]
@@ -0,0 +1,120 @@
- generic [active] [ref=f11e1]:
- link "Skip to main content" [ref=f11e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f11e3]:
- link "SchoolCompare home" [ref=f11e5] [cursor=pointer]:
- /url: /
- img [ref=f11e7]
- generic [ref=f11e11]: SchoolCompare
- navigation "Main navigation" [ref=f11e12]:
- link "Search" [ref=f11e13] [cursor=pointer]:
- /url: /
- img [ref=f11e15]
- generic [ref=f11e18]: Search
- link "1 selected Compare" [ref=f11e19] [cursor=pointer]:
- /url: /compare
- generic [ref=f11e20]:
- img [ref=f11e21]
- generic "1 selected" [ref=f11e24]: "1"
- generic [ref=f11e25]: Compare
- link "Rankings" [ref=f11e26] [cursor=pointer]:
- /url: /rankings
- img [ref=f11e28]
- generic [ref=f11e29]: Rankings
- link "Admissions" [ref=f11e30] [cursor=pointer]:
- /url: /admissions
- img [ref=f11e32]
- generic [ref=f11e35]: Admissions
- main [ref=f11e36]:
- generic [ref=f11e37]:
- generic [ref=f11e39]:
- generic [ref=f11e40]:
- heading "Compare Schools" [level=1] [ref=f11e41]
- paragraph [ref=f11e42]: Comparing 1 school
- generic [ref=f11e43]:
- button "+ Add School" [ref=f11e44] [cursor=pointer]
- button "Share" [ref=f11e45] [cursor=pointer]:
- img [ref=f11e46]
- text: Share
- generic [ref=f11e49]:
- button "Primary (1)" [ref=f11e50] [cursor=pointer]
- button "Secondary (0)" [ref=f11e51] [cursor=pointer]
- generic [ref=f11e52]:
- generic [ref=f11e53]: "Select Metric:"
- combobox "Select Metric:" [ref=f11e54] [cursor=pointer]:
- option "Reading, Writing & Maths Combined %" [selected]
- option "Reading Expected %"
- option "Writing Expected %"
- option "Maths Expected %"
- option "GPS Expected %"
- option "Science Expected %"
- option "Reading, Writing & Maths Combined Higher %"
- option "Reading Higher %"
- option "Writing Higher %"
- option "Maths Higher %"
- option "GPS Higher %"
- option "Reading Progress"
- option "Writing Progress"
- option "Maths Progress"
- option "Reading Average Score"
- option "Maths Average Score"
- option "GPS Average Score"
- option "Reading, Writing & Maths Expected % (Boys)"
- option "Reading, Writing & Maths Expected % (Girls)"
- option "Reading, Writing & Maths Higher % (Boys)"
- option "Reading, Writing & Maths Higher % (Girls)"
- option "Reading, Writing & Maths Expected % (Disadvantaged)"
- option "Reading, Writing & Maths Expected % (Non-Disadvantaged)"
- option "Disadvantaged Gap"
- option "% Disadvantaged Pupils"
- option "% EAL Pupils"
- option "% SEN Support"
- option "% Pupil Stability"
- option "Reading Test Absence %"
- option "GPS Test Absence %"
- option "Maths Test Absence %"
- option "Writing Absence %"
- option "Science Absence %"
- paragraph [ref=f11e55]: "% meeting expected standard in reading, writing and maths"
- generic [ref=f11e58]:
- button "Remove The Castle School" [ref=f11e59] [cursor=pointer]: ×
- heading "The Castle School" [level=2] [ref=f11e60]:
- link "The Castle School" [ref=f11e61] [cursor=pointer]:
- /url: /school/136916-the-castle-school
- generic [ref=f11e62]:
- generic [ref=f11e63]: Somerset
- generic [ref=f11e64]: Academy converter
- contentinfo [ref=f11e76]:
- generic [ref=f11e77]:
- generic [ref=f11e78]:
- generic [ref=f11e79]:
- heading "SchoolCompare" [level=3] [ref=f11e80]
- paragraph [ref=f11e81]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f11e82] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f11e83]:
- heading "Product" [level=4] [ref=f11e84]
- list [ref=f11e85]:
- listitem [ref=f11e86]:
- link "Search schools" [ref=f11e87] [cursor=pointer]:
- /url: /
- listitem [ref=f11e88]:
- link "Rankings" [ref=f11e89] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f11e90]:
- link "Compare shortlist" [ref=f11e91] [cursor=pointer]:
- /url: /compare
- listitem [ref=f11e92]:
- link "Admissions guide" [ref=f11e93] [cursor=pointer]:
- /url: /admissions
- generic [ref=f11e94]:
- heading "Resources" [level=4] [ref=f11e95]
- list [ref=f11e96]:
- listitem [ref=f11e97]:
- link "School Performance Tables" [ref=f11e98] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f11e99]:
- link "Ofsted reports" [ref=f11e100] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f11e102]: © 2026 SchoolCompare.co.uk
- alert [ref=f11e103]
@@ -0,0 +1,69 @@
- generic [active] [ref=f12e1]:
- link "Skip to main content" [ref=f12e2] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f12e3]:
- link "SchoolCompare home" [ref=f12e5] [cursor=pointer]:
- /url: /
- img [ref=f12e7]
- generic [ref=f12e11]: SchoolCompare
- navigation "Main navigation" [ref=f12e12]:
- link "Search" [ref=f12e13] [cursor=pointer]:
- /url: /
- img [ref=f12e15]
- generic [ref=f12e18]: Search
- link "Compare" [ref=f12e19] [cursor=pointer]:
- /url: /compare
- img [ref=f12e21]
- generic [ref=f12e24]: Compare
- link "Rankings" [ref=f12e25] [cursor=pointer]:
- /url: /rankings
- img [ref=f12e27]
- generic [ref=f12e28]: Rankings
- link "Admissions" [ref=f12e29] [cursor=pointer]:
- /url: /admissions
- img [ref=f12e31]
- generic [ref=f12e34]: Admissions
- main [ref=f12e35]:
- generic [ref=f12e36]:
- generic [ref=f12e37]:
- heading "Compare Schools" [level=1] [ref=f12e38]
- paragraph [ref=f12e39]: Add schools to your comparison basket to see side-by-side performance data
- generic [ref=f12e40]:
- img [ref=f12e42]
- heading "No schools selected" [level=3] [ref=f12e45]
- paragraph [ref=f12e46]: Add schools from the home page or search to start comparing.
- button "+ Add Schools to Compare" [ref=f12e47] [cursor=pointer]
- contentinfo [ref=f12e48]:
- generic [ref=f12e49]:
- generic [ref=f12e50]:
- generic [ref=f12e51]:
- heading "SchoolCompare" [level=3] [ref=f12e52]
- paragraph [ref=f12e53]: Compare primary and secondary schools across England.
- link "contact@schoolcompare.co.uk" [ref=f12e54] [cursor=pointer]:
- /url: mailto:contact@schoolcompare.co.uk
- generic [ref=f12e55]:
- heading "Product" [level=4] [ref=f12e56]
- list [ref=f12e57]:
- listitem [ref=f12e58]:
- link "Search schools" [ref=f12e59] [cursor=pointer]:
- /url: /
- listitem [ref=f12e60]:
- link "Rankings" [ref=f12e61] [cursor=pointer]:
- /url: /rankings
- listitem [ref=f12e62]:
- link "Compare shortlist" [ref=f12e63] [cursor=pointer]:
- /url: /compare
- listitem [ref=f12e64]:
- link "Admissions guide" [ref=f12e65] [cursor=pointer]:
- /url: /admissions
- generic [ref=f12e66]:
- heading "Resources" [level=4] [ref=f12e67]
- list [ref=f12e68]:
- listitem [ref=f12e69]:
- link "School Performance Tables" [ref=f12e70] [cursor=pointer]:
- /url: https://www.gov.uk/school-performance-tables
- listitem [ref=f12e71]:
- link "Ofsted reports" [ref=f12e72] [cursor=pointer]:
- /url: https://reports.ofsted.gov.uk/
- paragraph [ref=f12e74]: © 2026 SchoolCompare.co.uk
- alert [ref=f12e75]
Loaded 100 of 563 files, more files were not shown because too many files have changed in this diff. Show more