Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34511e459 | ||
|
|
1fc1e07d21 | ||
|
|
8ad2070e76 | ||
|
|
3aad5101a8 | ||
|
|
c47fe38971 | ||
|
|
8f211577c8 | ||
|
|
69f2201244 | ||
|
|
e6048c9ca6 | ||
|
|
7650b16f62 | ||
|
|
9abd020967 | ||
|
|
228eb214f5 | ||
|
|
ea5249a2ea |
No files matched your search
@@ -0,0 +1,85 @@
|
||||
# Remote branch cleanup, 2026-08-20
|
||||
# Restore any branch with: git push origin <sha>:refs/heads/<name>
|
||||
|
||||
## Deleted: fully merged into main (content is in main)
|
||||
75677f4252b759ef895e7d5f7c19f8f1745bdb59 add-contact-form-footer
|
||||
fa1abff642683dfd26ba88a295a0a6710d77147a chore/byline-removal-and-audit-figure
|
||||
95081d38bdf87764ef5d298676c25fae4cd3b792 chore/remove-parent-view
|
||||
6877abedebfc1c7d95f1f6ebe68945c62be528ab chore/staged-prod-promotion
|
||||
090d5f7bec824e083d3252e2c6e636686016304d ci/frontend-checks-speedup
|
||||
8c3a5cc4e9f551f0190d85357ce7741ad87f3a4c design/cohort-identity
|
||||
955659580067ca8b81bd77e31e4e2554103f094d feat/allow-analytics-iframe-embed
|
||||
6828f6cd4417284ea3eb6f088fa20945b8b40ed3 feat/compare-chips-two-per-row
|
||||
d5cd0abfee226885119665da5d2aa8288b59217f feat/compare-data-foundation
|
||||
6dd9b04b50bee146682da87efad8fc8b526251c5 feat/compare-frontend-rebuild
|
||||
96d5fcf5b07b6f175b48e9b20fcb765a320a907f feat/detail-header-details-reveal
|
||||
f1388ff5bd0af1409823a1e047b7ba84246a0f70 feat/gias-sixth-form-flag
|
||||
eddf74745f86c9c6d9eb07d867246ff9cc90dc20 feat/hero-artwork-v2
|
||||
3015c37bac6dc28db58c80fdb9235942c25b83d7 feat/hero-byline
|
||||
8e763e39d17a4964cf558e51c03f044186371f6d feat/info-popover-tooltip
|
||||
88c653215d520ab6e902c9de55bf27d86eeb90c3 feat/last-distance-offered
|
||||
a72323874f7aebdb2e64b6d64a5febd61152d09d feat/last-distance-offered-full
|
||||
c9a1892bfb0370e0672e5849cba294ddfabe3c65 feat/latest-cutoff-only
|
||||
4e8df006d75d8be2a1d8529ddf855c445854cba1 feat/near-me-by-search
|
||||
45ab479062c6a1639facad636fc0cc0cf0fd9155 feat/proposed-to-close-schools
|
||||
3bf2e8f262cbe058fda6de6f8ea3e050224a51b9 feat/school-detail-visualisations
|
||||
1f80571b1ff217dc92b660a936c02b5f49d07f0b feat/umami-heatmap-recorder
|
||||
609bb923d96aa5730131463ea35d5efdd404bf96 feature/ingest-independent-schools
|
||||
94151c58ea38a9256d66d15161293486a500c7a2 fix/admissions-section-height
|
||||
79246edc22961c2beb3520437e9d064d3b809d10 fix/annual-dag-ks4-national-selector
|
||||
59ac9c10b97e0ef1143f57fea06b324e72ac3d4a fix/chart-marker-contrast
|
||||
9f8dba227c95706ca3527bd48d381e7622cc0a5e fix/compare-chart-refetch-resilience
|
||||
e74d3882ce78a141fa1a57daa3102d7a58852dc3 fix/compare-expert-fixes
|
||||
80176cac4db4820e76ea2a156c7a2974bee2f203 fix/compare-final-review-mustfix
|
||||
f579630fab6c456e26a6984a3e8eebdbe3184202 fix/compare-mockup-drift
|
||||
dc85254ad2ddf134b4434065d4762cef374d2220 fix/compare-null-year-blanks-chart
|
||||
43a2c4a6bc539b621f31655aec05ef319a25f343 fix/compare-refresh-and-fetch
|
||||
d677b5453365b72c81d6df2de62b1fa0d05d684d fix/daily-dag-cache-invalidation
|
||||
f6bb037c471553e8195b5a8b147467ce0d07a688 fix/detail-all-through
|
||||
17bd4d5a5eb0b12ca79b97db587f14f7d671e85b fix/detail-chart-truthfulness
|
||||
4e6be0ce65647410b4ff74f8b763207c28920c26 fix/detail-inclusion-admissions
|
||||
fdda52ff0af3fae03a4b059a655973cdbb269f91 fix/detail-ofsted-correctness
|
||||
e36125b24aba254a8d15c5c33b4d2a296e691995 fix/detail-provenance-anchoring
|
||||
b31e71ac884df6507f567adc046c9fc52d9d310a fix/detail-report-card-render-date
|
||||
32f8a02862be6a1d49f4c3928b17fcf15d4c94cc fix/detail-trend-chart-taller
|
||||
e65688d600a86818fe21ae4c61ba27e5b6ec8d7c fix/e2e-brand-assertions
|
||||
3adea73ee04cdedfab54b0351878b297f72756ad fix/e2e-compare-chips-phase
|
||||
06e4898c30feaedc471f97aba28ddb0d61379f4d fix/e2e-compare-samephase
|
||||
9abd020967670a855e80fe5a908c8048a3aa9f14 fix/e2e-distance-locator
|
||||
acec8135e1ec7c9c3c255e5b23733a0ef862b590 fix/e2e-rankings-year-pick
|
||||
5944d88f0b1517ef1ef56af1b62270d2cf28e717 fix/expert-signoff-mustfixes
|
||||
2433101fa08be5df6f170d41790512ffe823d33e fix/font-cascade-and-map-palette
|
||||
74ca76d150deec6725259d9637ea86d7bb90c683 fix/gias-legacy-fallback
|
||||
bdaa05cd542f563ef74c45307cd8f7fc465193c9 fix/hero-fallback-and-sharp
|
||||
d52d384cf23d282b44e9251176f8f3402d808600 fix/hero-map-ios-fullscreen
|
||||
4043270a77bbe4edb18207fa5f1d94d4747fe12f fix/hero-mobile-and-wording
|
||||
22e9eb2d48b0d6623e88fd67cb6ca8e5e4074583 fix/homepage-education-accuracy
|
||||
8d50afef1e8a2b621b7344eadf475b0d609ad7a2 fix/leaflet-specificity-and-font-assertion
|
||||
b2b2cad5acf534ae7a667d3fb2be15efff37c4a7 fix/list-map-report-card-signal
|
||||
dc21e80a5e9eebd13aaab84735642eb77cef35e6 fix/mobile-cell-name-size
|
||||
e5f7f4c959f024c073472122d858333a0f24866c fix/mobile-compare-polish
|
||||
a00cbe916182d1e04661c750a1ce2e7ad27a68ae fix/mobile-sort-select-overflow
|
||||
2fd997bfe640c419a6713e85df008463de7f56e8 fix/modal-keyboard-viewport
|
||||
3e7705756776a0c44d27966dfc972023f1f38b69 fix/ofsted-link-text
|
||||
ce422e64363e2b03c186ef316832b6de15502d67 fix/promote-status-token
|
||||
4522cbf64560db1e1cd519e119aa466b42cd16a1 fix/proposed-to-close-copy
|
||||
15da060e4af37fbae919e0edf25f108266de2585 fix/rankings-admissions-accuracy
|
||||
6c872ce726f210433354ca38dc5314bc6a467534 fix/rankings-year-validation
|
||||
1c1df7796194af3d47f8e5ac0a0fbe6f323700e7 fix/report-card-chip-alignment
|
||||
b2dc4d0779ced02709429afaa5985acf4abda794 fix/results-map-ios-fullscreen
|
||||
95a5783da1fc994df76cb97238b55596dce4cd8f fix/runtime-api-proxy
|
||||
8a9ba30cc24e29653a72c03c0e817684b7db7c07 fix/sats-per-level-national
|
||||
536832a524fc4f9ed858f047e00b9a4d9d429c46 fix/school-detail-nan-500
|
||||
fef83b3bf244a9bf3cb4afa75dbc433d8725014f fix/schoolbar-sticky-offset
|
||||
b0c5b6bb57c879477da12c37b15cff950c29ebd3 fix/secondary-anchors-button-affordance
|
||||
261403bcd2b01aa4f26ee212e26302fc0f769bf9 fix/special-note-full-width
|
||||
ea5249a2ea6faf6bfa5a1522644387ba59770ec8 fix/standardise-distance-units
|
||||
e4565e9f158721d4df6b918f2b065de82851f8d9 fix/trends-chart-height
|
||||
3aad5101a842539105022f9059e85c224fc5973a fix/welsh-establishment-leak
|
||||
315f1feede70bdf3101d2fdd305b3d06e037fdac perf/batch-supplementary
|
||||
d2dc78aeb599e16b7ef5019be2b360b08df463bc perf/compare-loading
|
||||
e098ad4bd1130152705788d4773837b7d1e7112e perf/server-client-split
|
||||
|
||||
## Deleted: superseded by PR #110 (content preserved on feat/seo-crawl-hygiene-main)
|
||||
786ec80dd4de4a3cb674a89e35b3b5e461639289 feat/seo-crawl-hygiene
|
||||
a5ac0bcd1b37bc10dcbce88f8601d01bf7b3eaaf feat/england-only-corpus
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,309 @@
|
||||
# SEO Programme — Design
|
||||
|
||||
Date: 2026-08-20
|
||||
Status: awaiting review
|
||||
|
||||
## Problem
|
||||
|
||||
schoolcompare ranks second for "school compare" — an exact match for the
|
||||
brand and the domain. It ranks poorly for "compare schools", "school
|
||||
comparison" and "schools near me". The first is a naming artefact and
|
||||
transfers to nothing; the rest are the queries that actually carry parent
|
||||
demand.
|
||||
|
||||
The cause is structural, not editorial. The site publishes five route
|
||||
families:
|
||||
|
||||
/ /compare /rankings /admissions /school/[slug]
|
||||
|
||||
Location intent has no landing page at all. Every competitor outranking us
|
||||
on those queries wins with programmatic location pages:
|
||||
|
||||
| Competitor | URL pattern |
|
||||
|---------------|--------------------------------------------|
|
||||
| School Guide | `/best-schools-in/manchester` |
|
||||
| Locrating | `/the-best-primary-schools-in-Manchester_…`|
|
||||
| FindMySchool | `/best-primary-schools/manchester` |
|
||||
| Snobe | `/best-primary-schools/manchester` |
|
||||
| School Atlas | `/guides/best-primary-schools-manchester` |
|
||||
|
||||
"Schools near me" is a local-intent query. Google resolves it against the
|
||||
user's coordinates and serves pages that are *about a place*. A national
|
||||
homepage cannot win it. No title or description change fixes this; only
|
||||
pages Google can localise will.
|
||||
|
||||
## Baseline (measured 2026-08-20, production API)
|
||||
|
||||
| Measure | Value |
|
||||
|--------------------------------------------|---------|
|
||||
| Unique schools | 27,229 |
|
||||
| URLs in sitemap.xml | 27,232 |
|
||||
| Schools with 2024/25 performance data | 21,266 |
|
||||
| Schools with **no** current performance data| ~5,963 (22%) |
|
||||
| Welsh establishments (all metrics null) | 1,569 |
|
||||
| Overseas / offshore establishments | 467 |
|
||||
| Static URLs in sitemap | 3 |
|
||||
| Routes setting a canonical | 1 of 5 |
|
||||
|
||||
Three findings from that table drive the plan.
|
||||
|
||||
**We submit ~6,000 thin pages to Google.** `build_sitemap()`
|
||||
(`backend/app.py:73`) enumerates every URN regardless of whether the school
|
||||
has any data. Welsh establishments return `school_type: "Welsh
|
||||
establishment"` with every performance metric, Ofsted grade and phase field
|
||||
null. Overseas and offshore establishments ("BFPO Overseas Establishments",
|
||||
"Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are
|
||||
in the local-authority list too. At 22% of the submitted corpus this is a
|
||||
site-wide quality signal problem and a crawl-budget waste, not a rounding
|
||||
error.
|
||||
|
||||
W1 item 4 removes 2,036 of those — every non-England establishment — taking
|
||||
the corpus to 25,193. The 3,927 that remain are English schools with no
|
||||
current data: mostly newly opened, special, nursery or alternative provision.
|
||||
Those are a template problem, not a corpus problem, and item 5 handles them
|
||||
separately.
|
||||
|
||||
**The homepage is its own competitor.** `app/page.tsx` accepts eleven search
|
||||
params (`search`, `local_authority`, `school_type`, `phase`, `page`,
|
||||
`postcode`, `radius`, `sort`, `gender`, `admissions_policy`,
|
||||
`has_sixth_form`) and sets no canonical. Every filter combination is a
|
||||
crawlable near-duplicate of the single page we are asking to rank for
|
||||
"compare schools".
|
||||
|
||||
**School pages are near-orphans.** Reachable from the sitemap and from site
|
||||
search, but almost nothing links to them contextually, so they accrue no
|
||||
internal authority.
|
||||
|
||||
Also noted: the sitemap emits invented `priority` values and no `lastmod`.
|
||||
Google ignores `priority` and `changefreq` entirely; `lastmod` is the field
|
||||
it does read, and we omit it.
|
||||
|
||||
## Keyword clusters
|
||||
|
||||
Ranked by judgement of UK parent search behaviour and by the competitive
|
||||
SERP evidence above. Google Search Console is connected, so cluster
|
||||
priorities are to be re-derived from measured impressions before build
|
||||
starts (see Workstream 0).
|
||||
|
||||
**C1 — Head "compare" terms.** compare schools · school comparison · school
|
||||
comparison tool · compare school performance · compare primary schools ·
|
||||
compare secondary schools · compare two schools
|
||||
|
||||
**C2 — League tables and rankings.** primary school league tables ·
|
||||
secondary school league tables · school league tables 2026 · SATs results by
|
||||
school · GCSE results by school · KS2 league tables · Progress 8 rankings ·
|
||||
best primary schools in [town] · top 10 primary schools in [LA]
|
||||
|
||||
**C3 — Local / near me.** schools near me · primary schools near me ·
|
||||
secondary schools near me · best schools near me · good schools near me ·
|
||||
schools in [town] · primary schools in [LA] · schools near [postcode] ·
|
||||
[postcode] school catchment
|
||||
|
||||
**C4 — Individual school long tail.** [school] ofsted · [school] SATs
|
||||
results · [school] catchment area · [school] reviews · [school] URN
|
||||
|
||||
**C5 — Admissions.** primary school admissions 2027 · national offer day
|
||||
2027 · school application deadline · school admissions appeal ·
|
||||
oversubscription criteria · distance criteria school admissions · didn't get
|
||||
first choice school · school admissions [LA]
|
||||
|
||||
**C6 — Metric explainers.** what is a good SATs score · what is Progress 8 ·
|
||||
what is Attainment 8 · expected standard KS2 meaning · scaled score
|
||||
explained · Ofsted grades explained · Ofsted report cards · pupil premium
|
||||
explained
|
||||
|
||||
**C7 — Head to head.** [school A] vs [school B] · academy vs community
|
||||
school · grammar school vs comprehensive · faith school vs community school
|
||||
|
||||
## Workstreams
|
||||
|
||||
### W0 — Measure before touching anything
|
||||
|
||||
Export a Google Search Console baseline: impressions, clicks, average
|
||||
position and CTR by query and by page, for the trailing 16 months. Bucket
|
||||
queries into C1–C7. This sets the counterfactual — without it, no later
|
||||
claim about lift is defensible, because school-search traffic is strongly
|
||||
seasonal (results day in December, offer day in March/April).
|
||||
|
||||
Re-rank C1–C7 against measured impressions and adjust the sequence below if
|
||||
the data disagrees with the judgement calls.
|
||||
|
||||
### W1 — Crawl hygiene and index sanity
|
||||
|
||||
Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a
|
||||
corpus that is 22% thin would compound the existing problem.
|
||||
|
||||
1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions`
|
||||
currently set none.
|
||||
2. The homepage canonicalises to `/` regardless of search params.
|
||||
3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter
|
||||
space with no standalone value.
|
||||
4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the
|
||||
service/overseas schools are removed from the corpus at the mart boundary,
|
||||
not hidden at the view layer. `dim_school` and `dim_location` both exclude
|
||||
`TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service
|
||||
children's education, Welsh establishment, British schools overseas —
|
||||
listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`.
|
||||
That removes 2,036 establishments and 29 local authorities, and because
|
||||
`build_sitemap()` reads the same marts, it drops them from the sitemap in
|
||||
the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS
|
||||
refresh reintroduces them or if the two models drift apart.
|
||||
5. Prune the remaining thin pages: exclude any school with no performance data
|
||||
**and** no Ofsted record. Distinct from item 4 — these are English schools
|
||||
with nothing yet to show, so the fix may be a better template rather than
|
||||
removal.
|
||||
6. Rebuild the sitemap as a sitemap **index**: one child per page family,
|
||||
real `lastmod` from the data-load timestamp, `priority` and `changefreq`
|
||||
dropped.
|
||||
|
||||
### W2 — The location layer
|
||||
|
||||
The dominant lever. `dim_location` already carries `town`, `county`,
|
||||
`local_authority_name`, `parliamentary_constituency`, `latitude`,
|
||||
`longitude` and `postcode`, so no new ingestion is required.
|
||||
|
||||
Routes:
|
||||
|
||||
/schools/[la] e.g. /schools/manchester
|
||||
/schools/[la]/primary
|
||||
/schools/[la]/secondary
|
||||
/best-primary-schools/[town]
|
||||
/best-secondary-schools/[town]
|
||||
/schools/near/[outcode] e.g. /schools/near/m20
|
||||
/schools/near-me geolocating hub
|
||||
|
||||
**Thin-page threshold: generate a town or outcode page only where at least
|
||||
five schools have current performance data.** Below that, 301 to the parent
|
||||
LA page. This is the single most important constraint in the workstream —
|
||||
it is what separates a location layer from index bloat.
|
||||
|
||||
Each page must earn its place with content a parent would actually use, not
|
||||
a template shell:
|
||||
|
||||
- H1 matching the query intent ("Best primary schools in Manchester")
|
||||
- Counts framed usefully: "137 primary schools, 9 rated Outstanding"
|
||||
- A ranked table of the top 20 on the headline metric
|
||||
- Local average against the England average
|
||||
- Ofsted grade distribution
|
||||
- Map
|
||||
- Links to neighbouring towns and to the parent LA
|
||||
- An FAQ block (feeds `FAQPage` in W4)
|
||||
- Links to every school page in scope — this is what de-orphans W1's corpus
|
||||
|
||||
Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold
|
||||
≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**,
|
||||
comfortably inside a sitemap index and well under the per-file 50,000 limit.
|
||||
|
||||
### W3 — Make rankings indexable
|
||||
|
||||
`/rankings` is driven entirely by query params, so Google indexes
|
||||
approximately one page where there should be hundreds.
|
||||
|
||||
/rankings/[phase]/[metric]
|
||||
/rankings/[phase]/[metric]/[la]
|
||||
|
||||
The interactive filter UI stays; its state moves into real paths. Param
|
||||
forms canonicalise to the clean path. This is the direct play for C2.
|
||||
|
||||
### W4 — Structured data and internal linking
|
||||
|
||||
- Replace the bare `EducationalOrganization` on school pages with `School`,
|
||||
and populate it properly.
|
||||
- `BreadcrumbList` site-wide.
|
||||
- `ItemList` on every rankings and location page.
|
||||
- `FAQPage` on admissions and on location pages.
|
||||
- New school-page modules: "Other schools in [town]", "Nearby schools",
|
||||
"Compare with similar schools". Each links out to W2 and W3 pages, which
|
||||
is what circulates authority instead of stranding it.
|
||||
|
||||
Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus
|
||||
exists, and fabricating one would be both useless and a policy violation.
|
||||
|
||||
### W5 — Admissions expansion
|
||||
|
||||
One static page currently carries an entire cluster.
|
||||
|
||||
/admissions/[la] per-authority deadlines and offer day
|
||||
/admissions/appeals
|
||||
/admissions/national-offer-day
|
||||
|
||||
`school admissions [LA]` is high-intent and highly seasonal; per-authority
|
||||
pages are the natural unit.
|
||||
|
||||
### W6 — Explainer content
|
||||
|
||||
/guides/progress-8
|
||||
/guides/attainment-8
|
||||
/guides/sats-scaled-scores
|
||||
/guides/ofsted-grades
|
||||
/guides/expected-standard
|
||||
|
||||
Each links into the corresponding W3 rankings page. Cheap to build, and it
|
||||
is what gives the metric vocabulary enough topical weight to support C1–C4.
|
||||
|
||||
### W7 — Head-to-head pages
|
||||
|
||||
/compare/[school-a]-vs-[school-b]
|
||||
|
||||
C7 is uncontested and native to the product. It is also the easiest way to
|
||||
destroy everything W1 fixes: 27,229 schools generate 370 million pairs.
|
||||
**Curated pairs only** — same town, both with current data, both with real
|
||||
search demand — capped in the low thousands. Gated behind evidence that W2
|
||||
is indexing cleanly.
|
||||
|
||||
### W8 — Metadata rewrite for C1
|
||||
|
||||
Current homepage title is `schoolcompare | Compare every school in England`,
|
||||
which spends the most valuable position on the brand. Rewrite the homepage,
|
||||
rankings and compare titles and descriptions around C1 phrasing. Small
|
||||
change, and the cheapest item in the programme.
|
||||
|
||||
## Sequencing
|
||||
|
||||
W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly)
|
||||
|
||||
W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin
|
||||
compounds the problem rather than diluting it.
|
||||
|
||||
This spec is a programme, not a single implementation plan. Each workstream
|
||||
gets its own plan and its own PR; W2 will likely need several. Only W0 and W1
|
||||
are ready to plan against today — the rest should be re-read after W0's
|
||||
Search Console baseline lands, because that data may reorder them.
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in
|
||||
the same PR. Each workstream adds:
|
||||
|
||||
- W1: canonical present and correct on every route; `/compare?urns=` carries
|
||||
`noindex`; sitemap excludes a known dataless URN.
|
||||
- W2: a known LA, town and outcode page renders with the expected school
|
||||
count; a below-threshold town redirects to its LA.
|
||||
- W3: a clean rankings path renders; the param form canonicalises to it.
|
||||
- W4: JSON-LD parses and validates against the declared types.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat.** The failure mode of every programmatic SEO programme. The
|
||||
five-school threshold, the W1 prune and the W7 gate are the three controls.
|
||||
|
||||
**Helpful-content exposure.** Google's stance on templated location pages
|
||||
has hardened. The mitigation is that each page carries genuinely local
|
||||
computed data — real counts, real distributions, real local-vs-national
|
||||
comparison — rather than a name substituted into boilerplate.
|
||||
|
||||
**Build cost.** School pages already use ISR with a 7-day revalidate and
|
||||
`PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same
|
||||
treatment; full static generation of 32,000 pages is likely impractical in
|
||||
CI.
|
||||
|
||||
**Seasonality.** Results day and offer day dominate the traffic curve.
|
||||
Judging the programme on a mid-summer window would misread it in either
|
||||
direction. W0's baseline must be year-on-year, not month-on-month.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Catchment areas are Locrating's moat and a strong C3 driver
|
||||
(`[postcode] school catchment`). `fact_admissions` carries admission
|
||||
distances. Is deriving approximate catchment a later workstream, or out
|
||||
of scope?
|
||||
+196
-3
@@ -1252,7 +1252,11 @@ test('a published cut-off distance is shown with the year it belongs to', async
|
||||
await expect(label).toContainText(`September ${found!.distance.year}`);
|
||||
|
||||
// And the figure itself, in the unit councils publish in.
|
||||
await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible();
|
||||
// No trailing \b: the tile's support figure is an adjacent text node, so
|
||||
// the element reads "0.88 miles 1.4 km" and a word boundary after "miles"
|
||||
// is not guaranteed. The leading shape is what matters — a decimal figure
|
||||
// in miles, never a metric one.
|
||||
await expect(page.getByText(/\d+\.\d+ miles/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
|
||||
@@ -1340,7 +1344,27 @@ test('a school page shows no year-by-year cut-off record', async ({ page }) => {
|
||||
// history back without the API.
|
||||
await expect(page.getByText(/Last distance offered, by year/)).toHaveCount(0);
|
||||
await expect(page.getByText(/too few to read as a trend/)).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Distance' })).toHaveCount(0);
|
||||
|
||||
/*
|
||||
* No "Distance" tab in the admissions segmented control.
|
||||
*
|
||||
* Scoped to the control, and exact, because getByRole matches accessible
|
||||
* names by case-insensitive SUBSTRING: an unscoped { name: 'Distance' }
|
||||
* matched "Show this distance on a map" — the map toggle added by this same
|
||||
* feature — and failed a page that was entirely correct. Naming the group
|
||||
* this assertion is about also means unrelated copy elsewhere on the page
|
||||
* can never break it again.
|
||||
*/
|
||||
const viewToggle = page.getByRole('group', { name: 'Admissions view' });
|
||||
const tabs = await viewToggle.getByRole('button').allTextContents();
|
||||
|
||||
// Read the tabs positively rather than asserting an absence against a
|
||||
// locator that might resolve to nothing: if the group selector ever stops
|
||||
// matching, an absence check passes for the wrong reason, which is how the
|
||||
// bug this test is guarding would slip back in unnoticed.
|
||||
expect(tabs, 'admissions view toggle did not resolve').toContain('This year');
|
||||
expect(tabs.map((s) => s.trim()), `admissions tabs: ${tabs.join(', ')}`)
|
||||
.not.toContain('Distance');
|
||||
});
|
||||
|
||||
test('the postcode check answers for the published year, and names it', async ({ page }) => {
|
||||
@@ -1354,7 +1378,7 @@ test('the postcode check answers for the published year, and names it', async ({
|
||||
await expect(section).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel('Your postcode').fill('SW1A 1AA');
|
||||
await page.getByRole('button', { name: 'Check' }).click();
|
||||
await page.getByRole('button', { name: 'Check', exact: true }).click();
|
||||
|
||||
const result = page.getByRole('status');
|
||||
const error = page.getByRole('alert');
|
||||
@@ -1364,6 +1388,17 @@ test('the postcode check answers for the published year, and names it', async ({
|
||||
// A verdict without its year is a number a parent cannot place.
|
||||
await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`));
|
||||
await expect(result).toContainText(/inside|beyond|too close/);
|
||||
|
||||
// Both figures in the sentence must be in the same unit. The verdict used
|
||||
// to render the home distance in metres and the cut-off in miles — "69 m
|
||||
// away — inside the September 2026 cut-off of 0.17 miles" — which asks the
|
||||
// reader to convert between units to check a comparison already made for
|
||||
// them. Miles throughout: the unit councils publish and quote.
|
||||
const headline = (await result.textContent()) ?? '';
|
||||
const verdictLine = headline.split('.')[0];
|
||||
expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/);
|
||||
expect((verdictLine.match(/miles/g) ?? []).length,
|
||||
`verdict should quote two figures in miles: ${verdictLine}`).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1438,3 +1473,161 @@ test('no single section dominates the height of a school page', async ({ page })
|
||||
+ sections.map((s) => `${s.id}=${s.h}`).join(', '),
|
||||
).toBeLessThan(2.5);
|
||||
});
|
||||
|
||||
/*
|
||||
* England-only corpus.
|
||||
*
|
||||
* GIAS ships the whole UK plus overseas and offshore establishments, none of
|
||||
* which carry comparable DfE performance data. dim_school and dim_location
|
||||
* exclude them (vars.non_england_school_type_codes in dbt_project.yml), which
|
||||
* keeps them out of the site, the filter lists and the sitemap together.
|
||||
*
|
||||
* These assert the published surface, not the warehouse: the dbt test
|
||||
* assert_england_only_schools guards the marts, and these guard what the
|
||||
* environment actually serves once the marts have been rebuilt.
|
||||
*/
|
||||
|
||||
const WELSH_AUTHORITIES = [
|
||||
'Blaenau Gwent', 'Bridgend', 'Caerphilly', 'Cardiff', 'Carmarthenshire',
|
||||
'Ceredigion', 'Conwy', 'Denbighshire', 'Flintshire', 'Gwynedd',
|
||||
'Isle of Anglesey', 'Merthyr Tydfil', 'Monmouthshire', 'Neath Port Talbot',
|
||||
'Newport', 'Pembrokeshire', 'Powys', 'Rhondda Cynon Taf', 'Swansea',
|
||||
'Torfaen', 'Vale of Glamorgan', 'Wrexham',
|
||||
];
|
||||
|
||||
const NON_ENGLAND_AUTHORITIES = [
|
||||
'BFPO Overseas Establishments', 'Fieldwork Overseas Establishments',
|
||||
'Gibraltar Overseas Establishments', 'Guernsey Offshore Establishments',
|
||||
'Isle of Man Offshore Establishments', 'Jersey Offshore Establishments',
|
||||
'Scotland Offshore Establishments',
|
||||
];
|
||||
|
||||
const NON_ENGLAND_TYPES = [
|
||||
'Welsh establishment', 'Offshore schools',
|
||||
"Service children's education", 'British schools overseas',
|
||||
];
|
||||
|
||||
test('the local authority filter offers no Welsh or overseas authority', async ({ page }) => {
|
||||
const res = await page.request.get('/api/filters');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { local_authorities: las } = await res.json();
|
||||
|
||||
expect(Array.isArray(las)).toBeTruthy();
|
||||
// Guards against the list being empty, which would pass the check below
|
||||
// for the wrong reason.
|
||||
expect(las.length).toBeGreaterThan(100);
|
||||
|
||||
const leaked = [...WELSH_AUTHORITIES, ...NON_ENGLAND_AUTHORITIES]
|
||||
.filter((la) => las.includes(la));
|
||||
expect(leaked, `non-England authorities still offered: ${leaked.join(', ')}`)
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test('the school type filter offers no non-England establishment type', async ({ page }) => {
|
||||
const res = await page.request.get('/api/filters');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { school_types: types } = await res.json();
|
||||
|
||||
expect(Array.isArray(types)).toBeTruthy();
|
||||
expect(types.length).toBeGreaterThan(10);
|
||||
|
||||
const leaked = NON_ENGLAND_TYPES.filter((t) => types.includes(t));
|
||||
expect(leaked, `non-England types still offered: ${leaked.join(', ')}`)
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test('searching a Welsh authority by name returns no schools', async ({ page }) => {
|
||||
// Cardiff held 144 Welsh establishments and nothing else, so the authority
|
||||
// should now be absent from the corpus entirely rather than merely thinned.
|
||||
const res = await page.request.get('/api/schools?local_authority=Cardiff&page_size=1');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.total ?? (body.schools ?? []).length).toBe(0);
|
||||
});
|
||||
|
||||
test('a Welsh school GIAS does not type as Welsh is gone too', async ({ page }) => {
|
||||
// Beechwood College, Sully, CF64 5SE. GIAS types it "Special post 16
|
||||
// institution" (32), not "Welsh establishment" (30), so the establishment
|
||||
// type filter alone left it behind — it was the last Welsh school standing.
|
||||
const res = await page.request.get('/api/schools?local_authority=Vale%20of%20Glamorgan&page_size=1');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.total ?? (body.schools ?? []).length).toBe(0);
|
||||
|
||||
const page404 = await page.goto('/school/142458-beechwood-college');
|
||||
expect(page404?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('English schools with Welsh postcodes are kept', async ({ page }) => {
|
||||
// Royal Mail postcode areas straddle the border: these Gloucestershire
|
||||
// schools carry NP16/NP25 postcodes but are English schools with English
|
||||
// data. A postcode-based filter would have wrongly deleted them, so this
|
||||
// guards the fix from being "simplified" into one.
|
||||
for (const urn of [115639, 115655, 137382]) {
|
||||
const res = await page.request.get(`/api/schools/${urn}`);
|
||||
expect(res.ok(), `URN ${urn} should still resolve`).toBeTruthy();
|
||||
const { school_info } = await res.json();
|
||||
expect(school_info.local_authority).toBe('Gloucestershire');
|
||||
}
|
||||
});
|
||||
|
||||
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
|
||||
// Paired on purpose: the Welsh assertion alone would also pass if the whole
|
||||
// site were down, which is the failure this test most needs to distinguish.
|
||||
const english = await page.request.get('/api/schools?search=primary&per_page=1');
|
||||
expect(english.ok()).toBeTruthy();
|
||||
const [first] = (await english.json()).schools ?? [];
|
||||
expect(first, 'no English school available to compare against').toBeTruthy();
|
||||
|
||||
const good = await page.goto(`/school/${first.urn}-x`);
|
||||
expect(good?.status(), 'an English school should still resolve').toBeLessThan(400);
|
||||
|
||||
// Adamsdown Primary School, Cardiff — a Welsh establishment (URN 401559).
|
||||
const welsh = await page.goto('/school/401559-adamsdown-primary-school');
|
||||
expect(welsh?.status(), 'a Welsh school should no longer resolve').toBe(404);
|
||||
});
|
||||
|
||||
test('the sitemap submits no Welsh or overseas school', async ({ page }) => {
|
||||
const res = await page.request.get('/sitemap.xml');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const xml = await res.text();
|
||||
|
||||
const urlCount = (xml.match(/<url>/g) ?? []).length;
|
||||
expect(urlCount, 'sitemap looks empty or truncated').toBeGreaterThan(1000);
|
||||
|
||||
// 401559 (Cardiff) and 402426 (ACT Schools, Cardiff) were both submitted
|
||||
// before the England-only filter landed.
|
||||
expect(xml).not.toContain('/school/401559');
|
||||
expect(xml).not.toContain('/school/402426');
|
||||
});
|
||||
|
||||
/*
|
||||
* Staging must not be indexable (spec 2026-08-20, W1 hygiene).
|
||||
*
|
||||
* These journeys only ever run against staging — deploy.yml passes
|
||||
* STAGING_BASE_URL, and promote.yml only smoke-polls production without
|
||||
* Playwright — so asserting the noindex header here is safe.
|
||||
*/
|
||||
test('staging answers noindex, and stays crawlable so the noindex is seen', async ({ page }) => {
|
||||
const res = await page.request.get('/');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const tag = res.headers()['x-robots-tag'];
|
||||
expect(tag, 'staging must send X-Robots-Tag').toBeTruthy();
|
||||
expect(tag).toContain('noindex');
|
||||
|
||||
// The other half, and the reason this is one test rather than two: a
|
||||
// Disallow would stop Google fetching the page at all, so it would never
|
||||
// see the noindex above. The two only work together.
|
||||
const robots = await (await page.request.get('/robots.txt')).text();
|
||||
expect(robots).not.toMatch(/^\s*Disallow:\s*\/\s*$/mi);
|
||||
});
|
||||
|
||||
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
|
||||
const list = await page.request.get('/api/schools?search=primary&per_page=1');
|
||||
const [first] = (await list.json()).schools ?? [];
|
||||
expect(first, 'no school available').toBeTruthy();
|
||||
|
||||
const res = await page.request.get(`/school/${first.urn}-x`);
|
||||
expect(res.headers()['x-robots-tag']).toContain('noindex');
|
||||
});
|
||||
@@ -30,6 +30,17 @@ describe('primary detail page', () => {
|
||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
||||
});
|
||||
|
||||
it('keeps the figure and its metric support readable as two numbers', () => {
|
||||
// They are flex children with a CSS gap and nothing between them in the
|
||||
// text layer, which read as "0.48 miles770 m" to a screen reader and to any
|
||||
// text matcher. Cheap to lose again, so pinned.
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49 }) });
|
||||
|
||||
const tile = document.querySelector('[class*="admissionsTileDistance"]')!;
|
||||
expect(tile.textContent).toMatch(/0\.48 miles\s+770 m/);
|
||||
expect(tile.textContent).not.toMatch(/miles\d/);
|
||||
});
|
||||
|
||||
it('never shows the figure without saying it is not a catchment', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* place — so both are pinned here rather than left to the component.
|
||||
*/
|
||||
|
||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||
import {
|
||||
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
||||
} from '@/components/school/lastDistanceOffered';
|
||||
@@ -32,8 +32,19 @@ describe('formatCutoffDistance', () => {
|
||||
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
||||
});
|
||||
|
||||
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
|
||||
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
|
||||
it('stays in miles at short range, where it used to swap to metres', () => {
|
||||
// The swap made a single number easier to read and a comparison harder:
|
||||
// "69 m away — inside the cut-off of 0.17 miles" asked the reader to
|
||||
// convert between units to check a claim we had already made for them.
|
||||
expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' });
|
||||
expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/);
|
||||
});
|
||||
|
||||
it('describes a distance too short for two decimal places', () => {
|
||||
// Rather than a flat "0.00 miles", which reads as no distance at all.
|
||||
expect(formatMiles(5)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(0)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(20)).toBe('0.01 miles');
|
||||
});
|
||||
|
||||
it('returns null rather than a zero cut-off', () => {
|
||||
@@ -84,6 +95,36 @@ describe('describeCutoff', () => {
|
||||
// ── "Would we have got in?" ────────────────────────────────────────────
|
||||
|
||||
describe('compareToCutoff', () => {
|
||||
it('never states the two figures in different units', () => {
|
||||
/*
|
||||
* The reported defect: "69 m away — inside the September 2026 cut-off of
|
||||
* 0.17 miles". Both numbers are correct and the sentence is still useless,
|
||||
* because checking it means converting one of them.
|
||||
*
|
||||
* Swept across the range where the old formatter switched units, so a
|
||||
* future readability tweak to one figure cannot reintroduce the mismatch
|
||||
* in the other.
|
||||
*/
|
||||
const mixed: string[] = [];
|
||||
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
|
||||
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
|
||||
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
|
||||
const hasMetres = /\d\s?m\b/.test(headline);
|
||||
const milesCount = (headline.match(/miles/g) ?? []).length;
|
||||
// Two figures, both in miles, and no metric reading anywhere near them.
|
||||
if (hasMetres || milesCount !== 2) {
|
||||
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads back the reported case in one unit', () => {
|
||||
expect(compareToCutoff(69, 270, 2026).headline)
|
||||
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
|
||||
});
|
||||
|
||||
it('calls a clearly nearer home inside, and names the year', () => {
|
||||
const r = compareToCutoff(300, 800, 2026);
|
||||
expect(r.verdict).toBe('inside');
|
||||
|
||||
@@ -70,10 +70,16 @@ export function AdmissionsSection({
|
||||
{/* Spans both columns rather than taking a half-width cell. This is the
|
||||
figure parents come to the page for, and at tile width the two-line
|
||||
"0.31 miles / September 2025" pairing wraps badly. */}
|
||||
{/* The {' '} between the figure and its metric support is not decoration.
|
||||
The two are flex children, so the gap is drawn by CSS and the text layer
|
||||
had nothing between them: textContent read "0.88 miles1.4 km", which is
|
||||
what a screen reader announces and what any text matcher sees. Whitespace
|
||||
text nodes are not rendered as flex items, so this changes the reading
|
||||
without changing the layout. */}
|
||||
const distanceTile = cutoff && (
|
||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
||||
<dd className={styles.admissionsTileNum}>
|
||||
{cutoff.primary}
|
||||
{cutoff.primary}{' '}
|
||||
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
||||
</dd>
|
||||
<dt className={styles.admissionsTileLabel}>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function SecondaryAdmissionsSection({
|
||||
Last distance offered · {cutoff.entryYear}
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
{cutoff.primary}
|
||||
{cutoff.primary}{' '}
|
||||
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||
|
||||
export interface CutoffDisplay {
|
||||
/** Headline figure, e.g. "0.31 miles". */
|
||||
@@ -108,8 +108,13 @@ export function compareToCutoff(
|
||||
const verdict: CutoffVerdict =
|
||||
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
|
||||
|
||||
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
|
||||
const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} m`;
|
||||
// Both sides through the same formatter, with no fallback that could reach
|
||||
// for a different unit: the sentence compares these two numbers directly, so
|
||||
// they have to be in the same one. formatCutoffDistance returns null for a
|
||||
// zero or negative figure, and its old "N m" fallback here was one of the
|
||||
// ways a metres reading used to appear next to a miles one.
|
||||
const label = formatMiles(distanceM);
|
||||
const cutoffLabel = formatMiles(cutoffM);
|
||||
|
||||
// "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles
|
||||
// cut-off for September 2026": the figure carries its own unit word, which
|
||||
|
||||
+31
-17
@@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
|
||||
const METRES_PER_MILE = 1609.344;
|
||||
|
||||
/**
|
||||
* Format an admission cut-off distance.
|
||||
* A distance in miles, the unit UK school admissions is conducted in.
|
||||
*
|
||||
* Miles lead because that is the unit councils publish in — 90% of the source
|
||||
* rows are miles — and because it is the unit a parent has already been quoted
|
||||
* if they have read their council's booklet. The metric figure follows as
|
||||
* support rather than replacing it.
|
||||
* Councils publish cut-offs in miles — 90% of the collected source rows — and
|
||||
* it is the unit a parent has already been quoted in their council's booklet
|
||||
* and offer letter. Everything a reader is asked to compare therefore renders
|
||||
* through this one function, so two figures in the same sentence can never
|
||||
* arrive in different units.
|
||||
*
|
||||
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
|
||||
* the two swap round and metres lead instead.
|
||||
* That was not previously true. This formatter used to swap to metres below
|
||||
* 100m, on the reasoning that "0.04 miles" carries less for a reader than
|
||||
* "69 m". Taken one figure at a time that holds; taken in a sentence it
|
||||
* produced "69 m away — inside the September 2026 cut-off of 0.17 miles",
|
||||
* which asks the reader to convert between units to understand a comparison we
|
||||
* had already made for them. Legibility of a single number lost to coherence
|
||||
* of the pair.
|
||||
*
|
||||
* Below 0.01 miles the two decimal places run out rather than the unit being
|
||||
* wrong, so the figure is described instead of rounded to a flat "0.00 miles".
|
||||
*/
|
||||
export function formatMiles(metres: number): string {
|
||||
const miles = metres / METRES_PER_MILE;
|
||||
if (miles < 0.01) return 'under 0.01 miles';
|
||||
return `${miles.toFixed(2)} miles`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an admission cut-off distance: the headline figure in miles, and a
|
||||
* metric equivalent to support it.
|
||||
*
|
||||
* The metric figure is support, not an alternative — it appears beside the
|
||||
* miles figure, never instead of it, so nothing a reader compares is ever in
|
||||
* two units at once.
|
||||
*/
|
||||
export function formatCutoffDistance(
|
||||
metres: number | null | undefined
|
||||
): { primary: string; secondary: string } | null {
|
||||
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
||||
|
||||
const miles = metres / METRES_PER_MILE;
|
||||
|
||||
if (metres < 100) {
|
||||
return {
|
||||
primary: `${Math.round(metres)} m`,
|
||||
secondary: `${miles.toFixed(2)} miles`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primary: `${miles.toFixed(2)} miles`,
|
||||
primary: formatMiles(metres),
|
||||
secondary: metres < 1000
|
||||
? `${Math.round(metres / 10) * 10} m`
|
||||
: `${(metres / 1000).toFixed(1)} km`,
|
||||
|
||||
@@ -55,6 +55,37 @@ const nextConfig = {
|
||||
// Headers for caching and security
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
/*
|
||||
* Keep non-production hosts out of the index.
|
||||
*
|
||||
* Staging serves the same image as production off stx., so without
|
||||
* this it is a full crawlable duplicate of the site.
|
||||
*
|
||||
* 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 worse, blocking the crawl
|
||||
* means Google never fetches the page and never sees a noindex at all.
|
||||
* Staging therefore stays crawlable and answers "noindex" when crawled.
|
||||
*
|
||||
* Matched on the staging host explicitly rather than "any host that is
|
||||
* not production". The inverted form is tempting because it would cover
|
||||
* future environments automatically, but its failure mode is
|
||||
* deindexing production if the Host header ever arrives rewritten by a
|
||||
* proxy. This form's failure mode is a new environment being indexable
|
||||
* until someone adds it here — recoverable, where the other is not.
|
||||
*
|
||||
* Any new non-production hostname must be added to this list.
|
||||
*/
|
||||
source: '/:path*',
|
||||
has: [{ type: 'host', value: 'stx.schoolcompare.co.uk' }],
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Robots-Tag',
|
||||
value: 'noindex, nofollow',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
|
||||
@@ -11,6 +11,32 @@ seed-paths: ["seeds"]
|
||||
target-path: "target"
|
||||
clean-targets: ["target", "dbt_packages"]
|
||||
|
||||
# schoolcompare publishes England only. GIAS ships the whole UK plus overseas
|
||||
# and offshore establishments, none of which have comparable DfE performance
|
||||
# data — Wales does not publish on the English measures at all. Excluding them
|
||||
# at the mart boundary keeps them out of the site, the filter lists and the
|
||||
# sitemap together. Codes are TypeOfEstablishment, see seeds/gias_code_names.csv.
|
||||
# 25 = Offshore schools (Jersey, Guernsey, Isle of Man, Gibraltar)
|
||||
# 26 = Service children's education (BFPO, Fieldwork Overseas)
|
||||
# 30 = Welsh establishment
|
||||
# 37 = British schools overseas
|
||||
# dim_school, dim_location and assert_england_only_schools all read this list.
|
||||
vars:
|
||||
non_england_school_type_codes: [25, 26, 30, 37]
|
||||
|
||||
# Type alone is not enough. Beechwood College (URN 142458, Sully, CF64 5SE)
|
||||
# is a Welsh school that GIAS types as a Special post 16 institution (32)
|
||||
# rather than a Welsh establishment (30), so the type filter let it through.
|
||||
# LA code is the reliable discriminator: GIAS gives the 22 Welsh unitary
|
||||
# authorities the contiguous block 660-681, which English authorities never
|
||||
# use (they occupy 201-384 and 800-938).
|
||||
#
|
||||
# Filtering on postcode would be 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.
|
||||
welsh_la_code_min: 660
|
||||
welsh_la_code_max: 681
|
||||
|
||||
models:
|
||||
school_compare:
|
||||
staging:
|
||||
|
||||
@@ -31,5 +31,11 @@ select
|
||||
else null
|
||||
end as longitude
|
||||
from {{ ref('stg_gias_establishments') }} s
|
||||
-- Must match dim_school's status filter exactly (the API inner-joins the two).
|
||||
-- Must match dim_school's status and England filters exactly (the API
|
||||
-- inner-joins the two).
|
||||
where s.status_code in (1, 3)
|
||||
-- coalesce, not a bare NOT IN: a null type code would make the predicate
|
||||
-- null and drop the row silently. Unknown type is not grounds for exclusion.
|
||||
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
|
||||
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
|
||||
@@ -91,3 +91,10 @@ left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
|
||||
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
|
||||
-- GIAS flips to Closed — marts fully rebuild each run).
|
||||
where s.status_code in (1, 3)
|
||||
-- England only. dim_location must apply this filter identically (the API
|
||||
-- inner-joins the two). See vars in dbt_project.yml for what the codes are.
|
||||
-- coalesce, not a bare NOT IN: a null type code would make the predicate
|
||||
-- null and drop the row silently. Unknown type is not grounds for exclusion.
|
||||
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
|
||||
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Custom test: the published corpus is England only.
|
||||
--
|
||||
-- GIAS ships the whole UK plus overseas and offshore establishments. None of
|
||||
-- them carry comparable DfE performance data, so dim_school and dim_location
|
||||
-- filter them out (see vars.non_england_school_type_codes in dbt_project.yml).
|
||||
-- This test is the guard: a GIAS refresh that reintroduces them, or an edit
|
||||
-- that drops the filter from one of the two models, fails the pipeline rather
|
||||
-- than quietly republishing ~2,000 dataless pages to the site and the sitemap.
|
||||
|
||||
select
|
||||
urn,
|
||||
school_name,
|
||||
school_type_code
|
||||
from {{ ref('dim_school') }}
|
||||
where school_type_code in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||
|
||||
union all
|
||||
|
||||
-- dim_location is inner-joined to dim_school by the API, so it must filter
|
||||
-- identically. Anything here that dim_school does not have means the two
|
||||
-- models have drifted apart.
|
||||
select
|
||||
l.urn,
|
||||
null::text as school_name,
|
||||
null::integer as school_type_code
|
||||
from {{ ref('dim_location') }} l
|
||||
left join {{ ref('dim_school') }} s on l.urn = s.urn
|
||||
where s.urn is null
|
||||
|
||||
union all
|
||||
|
||||
-- Belt and braces on the Welsh LA code range.
|
||||
--
|
||||
-- The range 660-681 is documented GIAS structure, not something this project
|
||||
-- can verify from its own data — local_authority_code is not exposed anywhere
|
||||
-- the app can assert on. So the range does the filtering and the authority
|
||||
-- NAME does the checking: if 660-681 is ever wrong or incomplete, a Welsh
|
||||
-- authority reappears here and the pipeline fails loudly rather than the
|
||||
-- corpus quietly regrowing a Welsh tail.
|
||||
--
|
||||
-- Beechwood College (142458) is why this exists: a Welsh school GIAS types as
|
||||
-- a Special post 16 institution, which the type filter alone let through.
|
||||
select
|
||||
l.urn,
|
||||
l.local_authority_name as school_name,
|
||||
null::integer as school_type_code
|
||||
from {{ ref('dim_location') }} l
|
||||
where l.local_authority_name in (
|
||||
'Isle of Anglesey', 'Gwynedd', 'Conwy', 'Denbighshire', 'Flintshire',
|
||||
'Wrexham', 'Powys', 'Ceredigion', 'Pembrokeshire', 'Carmarthenshire',
|
||||
'Swansea', 'Neath Port Talbot', 'Bridgend', 'Vale of Glamorgan',
|
||||
'Rhondda Cynon Taf', 'Merthyr Tydfil', 'Caerphilly', 'Blaenau Gwent',
|
||||
'Torfaen', 'Monmouthshire', 'Newport', 'Cardiff'
|
||||
)
|
||||
Reference in new issue
Block a user