Compare commits

..
Author SHA1 Message Date
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
80 changed files with 4218 additions and 132 deletions

No files matched your search

+6
View File
@@ -5,3 +5,9 @@ __pycache__/
pipeline/transform/target/ pipeline/transform/target/
pipeline/transform/logs/ pipeline/transform/logs/
pipeline/transform/.user.yml 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/
+2
View File
@@ -632,6 +632,8 @@ async def get_school_details(request: Request, urn: int):
"census": supplementary.get("census"), "census": supplementary.get("census"),
"admissions": supplementary.get("admissions"), "admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [], "admissions_history": supplementary.get("admissions_history") or [],
"admission_distance": supplementary.get("admission_distance"),
"admission_distance_history": supplementary.get("admission_distance_history") or [],
"sen_detail": supplementary.get("sen_detail"), "sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"), "phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"), "deprivation": supplementary.get("deprivation"),
+39 -1
View File
@@ -18,7 +18,7 @@ from .config import settings
from .database import SessionLocal, engine from .database import SessionLocal, engine
from .models import ( from .models import (
DimSchool, DimLocation, KS2Performance, DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions, FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactDeprivation, FactFinance, FactPupilCharacteristics, FactDeprivation, FactFinance, FactPupilCharacteristics,
) )
from .ofsted_codes import ofsted_page_url, report_card_labels from .ofsted_codes import ofsted_page_url, report_card_labels
@@ -723,6 +723,17 @@ def _admissions_row_dict(a) -> dict:
} }
def _admission_distance_dict(d) -> dict:
"""Serialize one fact_admission_distance row for API responses."""
return {
"year": d.year,
"distance_m": d.distance_m,
"route_count": d.route_count,
"la_name": d.la_name,
"distance_unit_raw": d.distance_unit_raw,
}
def _census_dict(pc) -> dict: def _census_dict(pc) -> dict:
return { return {
"year": pc.year, "year": pc.year,
@@ -759,6 +770,8 @@ def _empty_supplementary() -> dict:
"census": None, "census": None,
"admissions": None, "admissions": None,
"admissions_history": [], "admissions_history": [],
"admission_distance": None,
"admission_distance_history": [],
"sen_detail": None, "sen_detail": None,
"phonics": None, "phonics": None,
"deprivation": None, "deprivation": None,
@@ -837,6 +850,31 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
_safe(_admissions) _safe(_admissions)
# Last distance offered — all years per URN, oldest first, with the latest
# also exposed on its own.
#
# The history was deliberately withheld at first: coverage is ragged (a
# school may have 2021 and 2026 and nothing between), and a plain series
# would draw a trend line straight through gaps that are absences of
# publication rather than absences of a cut-off. It is served now because
# the client distinguishes those gaps explicitly — see cutoffYearRows in
# lastDistanceOffered.ts, which classifies every year in the range and
# breaks the line rather than interpolating across it.
def _admission_distance():
rows = (
db.query(FactAdmissionDistance)
.filter(FactAdmissionDistance.urn.in_(urns))
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.asc())
.all()
)
history: dict = {urn: [] for urn in urns}
for d in rows:
history[d.urn].append(_admission_distance_dict(d))
for urn, rows_for_urn in history.items():
result[urn]["admission_distance_history"] = rows_for_urn
result[urn]["admission_distance"] = rows_for_urn[-1] if rows_for_urn else None
_safe(_admission_distance)
# Deprivation — one row per URN. # Deprivation — one row per URN.
def _deprivation(): def _deprivation():
rows = ( rows = (
+31
View File
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
admissions_policy = Column(String(100)) admissions_policy = Column(String(100))
class FactAdmissionDistance(Base):
"""Last distance offered — one row per URN per year.
Separate from FactAdmissions because the source is separate: EES publishes
admissions for the whole country, whereas cut-off distances exist only for
the local authorities that choose to publish them (57 at the time of
writing), and the two refresh on unrelated timetables.
"""
__tablename__ = "fact_admission_distance"
__table_args__ = (
Index("ix_admission_distance_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
# Straight-line distance in metres from the school to the last home offered
# a place that year.
distance_m = Column(Float)
# How many admission routes (ability bands, separate reception/junior
# intakes) were collapsed into distance_m. >1 means the figure is the
# furthest of several and the page must say so.
route_count = Column(Integer)
la_code = Column(Integer)
la_name = Column(String(100))
# The unit the council published in, so the page can lead with the unit a
# parent was given rather than always converting.
distance_unit_raw = Column(String(20))
source_file = Column(Text)
class FactPupilCharacteristics(Base): class FactPupilCharacteristics(Base):
"""School pupil composition from EES census — one row per URN per year.""" """School pupil composition from EES census — one row per URN per year."""
__tablename__ = "fact_pupil_characteristics" __tablename__ = "fact_pupil_characteristics"
+68 -5
View File
@@ -8,8 +8,31 @@ from backend import data_loader
from backend.data_loader import get_supplementary_data_batch from backend.data_loader import get_supplementary_data_batch
def _sort_key(criterion):
"""(column name, descending) for a SQLAlchemy order_by argument.
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
.element.
"""
name = getattr(criterion, "key", None)
if name is not None:
return name, False
element = getattr(criterion, "element", None)
name = getattr(element, "key", None)
return name, "DESC" in str(criterion).upper()
class _FakeQuery: class _FakeQuery:
"""Records that a query ran and serves canned rows filtered by an in-list.""" """Records that a query ran and serves canned rows filtered by an in-list.
order_by is honoured rather than ignored. The batch loader picks a row per
URN by position — first for "latest Ofsted", last for "latest cut-off
distance" — which is only correct because the database returned them
sorted. A double that drops the ORDER BY makes those picks depend on
fixture insertion order instead, so the test would pass with the sort
reversed or removed and prove nothing about the query.
"""
def __init__(self, recorder, model_name, rows): def __init__(self, recorder, model_name, rows):
self._rec = recorder self._rec = recorder
@@ -19,7 +42,20 @@ class _FakeQuery:
def filter(self, *args, **kwargs): def filter(self, *args, **kwargs):
return self return self
def order_by(self, *args, **kwargs): def order_by(self, *criteria):
for crit in reversed(criteria): # reversed = stable multi-key sort
name, descending = _sort_key(crit)
if not name:
continue
values = [getattr(r, name, None) for r in self._rows]
# Only sort on plainly comparable values. Some fixtures stand dates
# up as namespace objects, which raise on <; leaving those in their
# given order matches what the real query would produce for them.
if not all(isinstance(v, (int, float, str)) for v in values):
continue
self._rows = sorted(
self._rows, key=lambda r: getattr(r, name), reverse=descending
)
return self return self
def all(self): def all(self):
@@ -69,6 +105,13 @@ def _adm_row(urn, year):
) )
def _dist_row(urn, year, distance_m, route_count=1):
return types.SimpleNamespace(
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
)
def test_one_query_per_table_and_latest_row_per_urn(): def test_one_query_per_table_and_latest_row_per_urn():
rows = { rows = {
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023). # URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
@@ -78,6 +121,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
_ofsted_row(2, "2021-06-01", 1), _ofsted_row(2, "2021-06-01", 1),
], ],
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)], "FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
# URN 1 has three years of cut-offs; only the most recent is served.
# Deliberately not in year order — the ordering is the query's job.
"FactAdmissionDistance": [
_dist_row(1, 2026, 529.47),
_dist_row(1, 2024, 772.49),
_dist_row(1, 2025, 1421.05),
_dist_row(2, 2023, 2029.38, route_count=4),
],
"FactPupilCharacteristics": [], "FactPupilCharacteristics": [],
"FactDeprivation": [], "FactDeprivation": [],
"FactFinance": [], "FactFinance": [],
@@ -85,10 +136,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
session = _FakeSession(rows) session = _FakeSession(rows)
out = get_supplementary_data_batch(session, [1, 2]) out = get_supplementary_data_batch(session, [1, 2])
# Exactly one query per table — five total, regardless of two URNs. # Exactly one query per table — six total, regardless of two URNs.
assert sorted(session.queries) == [ assert sorted(session.queries) == [
"FactAdmissions", "FactDeprivation", "FactFinance", "FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
"FactOfstedInspection", "FactPupilCharacteristics", "FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
] ]
# Latest Ofsted kept per URN # Latest Ofsted kept per URN
@@ -100,6 +151,16 @@ def test_one_query_per_table_and_latest_row_per_urn():
assert out[1]["admissions"]["year"] == 202627 assert out[1]["admissions"]["year"] == 202627
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}] assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
# Cut-off distance: full history oldest-first, with the newest year also
# exposed on its own. The fixture rows are deliberately out of order, so
# this only passes if the query's ORDER BY is doing the work.
assert [r["year"] for r in out[1]["admission_distance_history"]] == [2024, 2025, 2026]
assert out[1]["admission_distance"]["year"] == 2026
assert out[1]["admission_distance"]["distance_m"] == 529.47
# route_count travels with the figure — the page needs it to say the
# distance is the furthest of several bands rather than the only one.
assert out[2]["admission_distance"]["route_count"] == 4
# Empty tables degrade to the null block, not a crash # Empty tables degrade to the null block, not a crash
assert out[1]["census"] is None and out[1]["deprivation"] is None assert out[1]["census"] is None and out[1]["deprivation"] is None
@@ -109,3 +170,5 @@ def test_single_wrapper_matches_batch(monkeypatch):
single = data_loader.get_supplementary_data(session, 5) single = data_loader.get_supplementary_data(session, 5)
assert single["ofsted"]["overall_effectiveness"] == 2 assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == [] assert single["admissions_history"] == []
assert single["admission_distance"] is None
assert single["admission_distance_history"] == []
@@ -19,7 +19,7 @@
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away". - Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.** **Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box). - Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
- Name search identical, correct, instant (`j1-search-results-desktop.png`). - Name search identical, correct, instant (`j1-search-results-desktop.png`).
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons). - Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass. - 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
@@ -57,7 +57,7 @@
- Severity guess: P2 - Severity guess: P2
- **F6. Mobile hero omits the value proposition shown on desktop** - **F6. Mobile hero omits the value proposition shown on desktop**
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box. - Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy). - Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate. - Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport) - Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
+366 -4
View File
@@ -121,6 +121,33 @@ test('the home page has a real title', async ({ page }) => {
expect(await page.title()).toMatch(/schoolcompare/i); expect(await page.title()).toMatch(/schoolcompare/i);
}); });
test('the site states its independence and never claims to be official', async ({ page }) => {
await page.goto('/');
// Carried in the footer, so it appears on every page rather than only here.
await expect(
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
).toBeVisible();
/*
* Describing the DATA as official is accurate and appears throughout
* ("official DfE figures", "the official figure isn't in"). Describing the
* SITE as official is not: schoolcompare republishes government data, it is
* not a government service. The difference is the grammatical subject, so
* this looks for text that opens with "Official" as a standalone label —
* the shape a value-prop title or heading takes, and the shape the old
* "Official & trusted" took.
*/
const claims = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.flatMap(el => [...el.childNodes]
.filter(n => n.nodeType === 3)
.map(n => (n.textContent ?? '').trim()))
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
);
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
});
test('the landing bands share one vertical rhythm', async ({ page }) => { test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const gaps = await page.evaluate(() => { const gaps = await page.evaluate(() => {
@@ -773,6 +800,108 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
* These are silent failures — nothing on the page looks wrong — so they need * These are silent failures — nothing on the page looks wrong — so they need
* a gate. * a gate.
*/ */
/**
* Chart benchmark markers, against whatever they actually overlap.
*
* The national-average marker shipped as var(--brand) on both templates —
* identical to the bar fill it sits on, so it measured 1.00:1 wherever it
* crossed a bar and was visible only for schools BELOW the benchmark.
*
* The WCAG journey below never saw it, and could not have: it composites
* backgrounds by walking the ANCESTOR chain, while these markers are
* absolutely positioned over a SIBLING. Ancestor-walking is structurally blind
* to overlap. This test asks the stacking order instead —
* document.elementsFromPoint returns what is genuinely beneath a point — which
* is the same question a reader's eye asks.
*/
const MARKER_PROBE = `(() => {
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
const markers = [...document.querySelectorAll('[class*="natTick"], [class*="NatLine"]')]
.filter(el => el.getBoundingClientRect().width > 0);
const out = { count: markers.length, failures: [], unmeasured: 0 };
for (const el of markers) {
/*
* Scroll it in first. elementsFromPoint takes VIEWPORT coordinates and
* returns an empty stack for anything off-screen — and these markers sit
* well down the page. Without this the backdrop silently defaults to
* white, which makes a teal-on-teal marker look like teal-on-white and
* pass. 'instant' because globals.css sets scroll-behavior: smooth on
* html, and a smooth scroll would still be moving when we measure.
*/
el.scrollIntoView({ block: 'center', behavior: 'instant' });
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const own = ps(getComputedStyle(el).backgroundColor);
const edge = ps((getComputedStyle(el).boxShadow.match(/rgba?\\([^)]*\\)/) || [])[0] || '');
if (!own) continue;
// What is genuinely underneath, by stacking order rather than by ancestry.
const stack = document.elementsFromPoint(cx, cy);
if (stack.length === 0) { out.unmeasured++; continue; } // never assume white
let backdrop = null;
for (const under of stack) {
if (under === el || el.contains(under)) continue;
const c = ps(getComputedStyle(under).backgroundColor);
if (c && c.a > 0.9) { backdrop = c; break; }
}
if (!backdrop) { out.unmeasured++; continue; }
// A knockout marker only needs ONE of its two parts to separate.
const best = Math.max(RT(ov(own, backdrop), backdrop),
edge ? RT(ov(edge, backdrop), backdrop) : 0);
if (best < 3) {
out.failures.push((el.className || '?').toString().split(' ')[0] +
' ' + best.toFixed(2) + ':1 over rgb(' +
[backdrop.r, backdrop.g, backdrop.b].map(Math.round).join(',') + ')');
}
}
return out;
})()`;
async function firstSchoolLink(page: Page, query: string): Promise<string> {
await page.goto(`/?search=${encodeURIComponent(query)}`);
const link = schoolLinks(page).first();
await expect(link).toBeVisible({ timeout: 15_000 });
return (await link.getAttribute('href'))!;
}
for (const scheme of ['light', 'dark'] as const) {
test(`chart benchmark markers stay visible in the ${scheme} theme`, async ({ browser }) => {
const context = await browser.newContext({ colorScheme: scheme });
const page = await context.newPage();
const failures: string[] = [];
// One primary (SATs bars) and one secondary (Attainment 8 bar) — the two
// templates carry separate implementations of the same marker.
for (const query of ['primary', 'academy']) {
const href = await firstSchoolLink(page, query);
await page.goto(href);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(900); // bar widths animate in from an effect
const res = (await page.evaluate(MARKER_PROBE)) as
{ count: number; failures: string[]; unmeasured: number };
test.skip(res.count === 0 && query === 'primary',
'no benchmark markers rendered — environment has no national averages');
// A marker we could not resolve a backdrop for is a hole in the check,
// not a pass. Fail loudly rather than quietly measuring nothing.
expect(res.unmeasured,
`${href}: ${res.unmeasured} of ${res.count} markers had no resolvable backdrop`).toBe(0);
failures.push(...res.failures.map(f => `${href} → ${f}`));
}
await context.close();
expect(failures, `benchmark markers under 3:1 in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
});
}
test('the brand asset set is complete and served', async ({ page }) => { test('the brand asset set is complete and served', async ({ page }) => {
const response = await page.goto('/'); const response = await page.goto('/');
expect(response?.ok()).toBe(true); expect(response?.ok()).toBe(true);
@@ -789,9 +918,21 @@ test('the brand asset set is complete and served', async ({ page }) => {
expect(og.ok()).toBe(true); expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png'); expect(og.headers()['content-type']).toContain('image/png');
// iOS ignores SVG touch icons, so this must be a real raster. /*
const apple = await page.request.get('/apple-icon'); * iOS ignores SVG touch icons, so this must be a real raster.
expect(apple.ok()).toBe(true); *
* Read the href off the page rather than hardcoding a path. The route moved
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
* with a content hash. This test kept asking for /apple-icon and got a 404 —
* reporting a missing icon while the icon was present and correctly linked.
*/
const appleLink = page.locator('link[rel="apple-touch-icon"]');
await expect(appleLink).toHaveCount(1);
const appleHref = await appleLink.getAttribute('href');
expect(appleHref).toBeTruthy();
const apple = await page.request.get(appleHref!);
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
expect(apple.headers()['content-type']).toContain('image/png'); expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon. // Android needs a maskable PNG or the install prompt has no icon.
@@ -912,8 +1053,22 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
// hashed at build time and change on any unrelated edit. // hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/); await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
const mark = home.locator('svg').first(); /*
* The mark is raster artwork, not an inline SVG — it has been since the
* supplied logo replaced the reconstruction, and this locator went on
* looking for an <svg> that no longer exists.
*
* Assert on naturalWidth rather than visibility: a <picture> whose sources
* all 404 still lays out and still passes toBeVisible(), so the weaker
* assertion would go green on a broken lockup.
*/
const mark = home.locator('img').first();
await expect(mark).toBeVisible(); await expect(mark).toBeVisible();
const markState = await mark.evaluate((el: HTMLImageElement) => ({
loaded: el.complete && el.naturalWidth > 0,
src: el.currentSrc,
}));
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
}); });
test('no visible text falls back to the browser default black', async ({ page }) => { test('no visible text falls back to the browser default black', async ({ page }) => {
@@ -1055,3 +1210,210 @@ for (const scheme of ['light', 'dark'] as const) {
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]); expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
}); });
} }
// ── Last distance offered ────────────────────────────────────────────────
//
// Coverage is partial by nature: only 57 local authorities publish cut-off
// distances at all, and staging may not yet hold the extract. These tests
// therefore locate a school that HAS a figure and assert the invariants that
// make it safe to show, rather than asserting any particular school has one.
/** URNs known to be in the collected cut-off dataset, spread across LAs so a
* single council's data gap cannot skip the whole check. */
const CUTOFF_CANDIDATE_URNS = [
139837, 100010, 100011, 100028, 100029, 130342, // Camden
100458, 135587, // Islington (banded)
101099, 100553, 102574, 100769, // mixed
];
async function schoolWithCutoff(page: Page, minPublishedYears = 1) {
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const body = await res.json();
if (body?.admission_distance?.distance_m == null) continue;
const published = (body.admission_distance_history ?? [])
.filter((h: { distance_m?: number | null }) => h.distance_m != null);
if (published.length < minPublishedYears) continue;
return {
urn,
distance: body.admission_distance,
history: published,
phase: body.school_info?.phase,
};
}
return null;
}
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
const label = page.getByText(/Last distance offered/).first();
await expect(label).toBeVisible();
// The year is the difference between a fact and a floating number: a cut-off
// is the outcome of one admissions round and means nothing without it.
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();
});
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/not a fixed catchment/)).toBeVisible();
// The old secondary template asserted "distance cut-off data is not
// available for this school" on every page, including ones whose council
// does publish it. Showing both at once would be a flat contradiction.
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
// A banded school's figure is the widest of several routes, and must say so.
if ((found!.distance.route_count ?? 1) > 1) {
await expect(page.getByText(/admission routes at this school/)).toBeVisible();
}
});
test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => {
// buildNavItems and the section composers gate on the same condition; when
// they drift the nav links to an anchor that was never rendered.
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#admissions')).toHaveCount(1);
});
test('no school page shows an implausible cut-off distance', async ({ page }) => {
// The source carries transcription errors up to 533 miles, filtered in
// stg_school_distance. This asserts the filter is actually reaching the API,
// because a visibly absurd figure on a live page is the failure that would
// cost the site its credibility.
let checked = 0;
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const d = (await res.json())?.admission_distance;
if (d?.distance_m == null) continue;
checked += 1;
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
.toBeGreaterThanOrEqual(25);
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
.toBeLessThanOrEqual(25000);
}
test.skip(checked === 0, 'no cut-off distances available to check yet');
});
// ── The Distance view, map and postcode check ────────────────────────────
test('a school with several published years gets the Distance view', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
const isSecondary = /secondary/i.test(found!.phase ?? '');
if (!isSecondary) {
// Primary pages carry a segmented control; the detail sits behind it.
const distanceTab = page.getByRole('button', { name: 'Distance' });
await expect(distanceTab).toBeVisible();
await distanceTab.click();
await expect(distanceTab).toHaveAttribute('aria-pressed', 'true');
}
// Every published year must appear as a row, whichever template rendered it.
for (const h of found!.history as { year: number }[]) {
await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible();
}
});
test('the year table never leaves a gap unexplained', async ({ page }) => {
// A blank row would tell a parent nothing; each gap must name its reason.
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const statuses = await page.locator('[class*="cutoffPill"]').allTextContents();
expect(statuses.length).toBeGreaterThan(0);
for (const s of statuses) {
expect(s.trim(), 'every row carries a status').not.toBe('');
}
// The stronger claim the data cannot support must never appear.
await expect(page.getByText(/every applicant was offered/i)).toHaveCount(0);
});
test('the postcode check answers with a distance and per-year verdicts', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const input = page.getByLabel('Your postcode');
await expect(input).toBeVisible();
await input.fill('SW1A 1AA');
await page.getByRole('button', { name: 'Check' }).click();
// Either a verdict or a plain error — never a silent no-op.
const result = page.getByRole('status');
const error = page.getByRole('alert');
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
if (await result.isVisible()) {
await expect(result).toContainText(/mile|m\b/);
}
});
test('the postcode check states its limits before it is used', async ({ page }) => {
// The caveat is not revealed with the answer — it is there while the parent
// is deciding whether to trust the answer at all.
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
await expect(page.getByText(/An indication only/)).toBeVisible();
await expect(page.getByText(/not a catchment boundary/)).toBeVisible();
});
test('the distance section never scrolls the page sideways', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
// The year table is deliberately wider than a phone; its own wrapper has to
// absorb that, or the whole page slides under the reader's thumb.
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
});
+591
View File
@@ -0,0 +1,591 @@
<title>Last distance offered — detail page mockup</title>
<style>
:root{
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
--accent-coral:#e07256; --accent-coral-dark:#b04a2e;
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
--serif:'Playfair Display',Georgia,'Iowan Old Style',serif;
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
}
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;
padding:44px 20px 110px;font-variant-numeric:tabular-nums}
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
.pagehead p + p{margin-top:8px}
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
/* ---- card shell mirrors SchoolDetailView .card ---- */
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
box-shadow:var(--shadow);padding:26px 26px 24px}
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;
color:var(--text-muted);padding:6px 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
/* ---- tiles ---- */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
.tile.accent{background:var(--coral-bg)}
.tile.accent .num{color:var(--accent-coral-dark)}
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
.tile.newtile .num{color:var(--accent-coral-dark)}
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
/* ---- verdict banner ---- */
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
.verdict.hard{background:var(--coral-bg)}
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
/* ---- chart ---- */
.chartwrap{overflow-x:auto}
.chart{display:block;width:100%;min-width:460px;height:auto}
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
.keyrow span{display:inline-flex;align-items:center;gap:7px}
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
.kdot.line{background:var(--accent-coral)}
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
/* ---- table ---- */
.tblwrap{overflow-x:auto;margin-top:4px}
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
thead th:first-child{text-align:left}
tbody td{text-align:right;padding:11px 10px;border-bottom:1px solid var(--border)}
tbody td:first-child{text-align:left;font-weight:600}
tbody tr:last-child td{border-bottom:none}
tbody tr.now{background:var(--bg-secondary)}
td.miss{color:var(--text-muted);font-weight:400}
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
/* ---- map ---- */
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
.mapsvg{display:block;width:100%;height:auto}
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
.maplegend span{display:inline-flex;align-items:center;gap:7px}
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
/* ---- postcode check ---- */
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
.checkform{display:flex;gap:8px;flex-wrap:wrap}
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
.checkform button:hover{background:#9c3f26}
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
/* ---- notes / disclosure ---- */
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
.note .i{flex:none;width:17px;height:17px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);
font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
.disclosure{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;
font-weight:600;font-size:14px;color:var(--accent-teal)}
.disclosure>summary::-webkit-details-marker{display:none}
.disclosure>summary:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
.disclosure .chev{transition:transform .2s ease}
.disclosure[open]>summary .chev{transform:rotate(90deg)}
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
.disclosure .body b{color:var(--text-primary)}
/* ---- viewport stack (keeps card height stable across views) ---- */
.viewport{display:grid}
.viewport>.view{grid-area:1/1}
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
/* ---- edge cases ---- */
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
.case .body strong{color:var(--text-primary)}
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
/* ---- phone ---- */
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
.phone .sectionTitle{font-size:18px}
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
.phone .tile{padding:11px 12px}
.phone .tile .num{font-size:22px}
.phone .chart{min-width:0}
.phone .chartwrap{overflow:visible}
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
<div class="wrap">
<div class="pagehead">
<h1>Last distance offered — school detail page</h1>
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
view on the map. Data covers one to ten years depending on the school and local authority, so every
screen here is built around partial coverage rather than assuming a full run.</p>
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
</div>
<!-- ============ 1. ADMISSIONS CARD ============ -->
<div class="step">
<h2>1. A distance tile joins the admissions tiles</h2>
<span class="where">SchoolDetailView · #admissions</span>
</div>
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
third view, <code>Distance</code>.</p>
<div class="card">
<div class="cardhead">
<h2 class="sectionTitle">Admissions</h2>
<div class="seg" role="group" aria-label="Admissions view">
<button type="button" aria-pressed="true" data-view="year">This year</button>
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
</div>
</div>
<p class="sectionSub">Reception entry, September 2025.</p>
<div class="viewport">
<!-- view: this year -->
<div class="view" id="v-year">
<dl class="tiles">
<div class="tile">
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
</div>
<div class="tile">
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
</div>
<div class="tile accent">
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
<dt class="lbl">Got their first choice</dt>
</div>
<div class="tile newtile">
<span class="newflag">New</span>
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
<dt class="lbl">Last distance offered</dt>
</div>
</dl>
<div class="note">
<span class="i" aria-hidden="true">i</span>
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
It is not a fixed catchment — it moves every year with the number of applications.</span>
</div>
</div>
<!-- view: distance -->
<div class="view" id="v-dist" hidden>
<div class="verdict hard">
<span class="vic" aria-hidden="true">↓</span>
<div>
<div class="vhead">The catchment has halved in nine years</div>
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
</div>
</div>
<div class="chartwrap">
<svg class="chart" viewBox="0 0 700 250" role="img"
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
<!-- grid -->
<g stroke="#e5dfd5" stroke-width="1">
<line x1="46" y1="30" x2="686" y2="30"/>
<line x1="46" y1="80" x2="686" y2="80"/>
<line x1="46" y1="130" x2="686" y2="130"/>
<line x1="46" y1="180" x2="686" y2="180"/>
</g>
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
<g class="axtxt" text-anchor="end">
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
</g>
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
<!-- gap segments (dashed = no figure published) -->
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
<path d="M114 92 L182 110"/>
<path d="M318 122 L386 132.5"/>
</g>
<!-- solid series -->
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="46,75 114,92"/>
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="182,110 250,102 318,122"/>
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="386,132.5 454,142.5 522,152.5"/>
<!-- points -->
<g fill="#e07256" stroke="#fff" stroke-width="2">
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
<circle cx="454" cy="142.5" r="5"/>
</g>
<!-- latest, emphasised -->
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
<!-- "no cut-off needed" marker, 2022 -->
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
<!-- endpoint labels -->
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
<!-- year axis -->
<g class="axtxt" text-anchor="middle">
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
</g>
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
</svg>
</div>
<div class="keyrow">
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
</div>
<div class="tblwrap" style="margin-top:22px">
<table>
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
<thead>
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
</thead>
<tbody>
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
</tbody>
</table>
</div>
<details class="disclosure">
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
<div class="body">
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
in a given year. Move one large sibling cohort and the figure shifts.</span>
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
authorities use walking routes, which are always longer for the same home.</span>
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
</div>
</details>
</div>
</div>
</div>
<!-- ============ 2. MAP ============ -->
<div class="step">
<h2>2. The cut-off drawn on the map</h2>
<span class="where">SchoolHeroMap · catchment layer</span>
</div>
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
<div class="card">
<div class="cardhead" style="margin-bottom:14px">
<h2 class="sectionTitle">Where the last place went</h2>
<div class="seg" role="group" aria-label="Map years">
<button type="button" aria-pressed="true">2025 only</button>
<button type="button" aria-pressed="false">Last 5 years</button>
</div>
</div>
<figure class="mapfig">
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
<rect width="700" height="400" fill="#eef2ec"/>
<!-- park -->
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
<!-- water -->
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
<!-- street grid -->
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
</g>
<g stroke="#fff" stroke-width="3.5" opacity=".8">
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
</g>
<!-- building blocks -->
<g fill="#e4e2dc" opacity=".85">
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
</g>
<!-- historic rings (hairline) -->
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
<circle cx="300" cy="200" r="150"/>
<circle cx="300" cy="200" r="126"/>
<circle cx="300" cy="200" r="106"/>
<circle cx="300" cy="200" r="90"/>
</g>
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
<!-- current ring -->
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
<!-- school pin -->
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
<!-- your home -->
<g transform="translate(246,158)">
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
</g>
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
</svg>
</figure>
<div class="maplegend">
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
</div>
<div class="checkbox">
<div class="checkhead">Would you have got in?</div>
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
<form class="checkform" onsubmit="return false">
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
<button type="submit">Check</button>
</form>
<div class="result">
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
figure, and in 2022 every applicant was offered a place.</div>
<div class="yearstrip">
<span class="yr in">2016 ✓</span>
<span class="yr in">2017 ✓</span>
<span class="yr none">2018 –</span>
<span class="yr in">2019 ✓</span>
<span class="yr in">2020 ✓</span>
<span class="yr in">2021 ✓</span>
<span class="yr in">2022 ✓</span>
<span class="yr in">2023 ✓</span>
<span class="yr in">2024 ✓</span>
<span class="yr in">2025 ✓</span>
</div>
</div>
<div class="note">
<span class="i" aria-hidden="true">!</span>
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
</div>
</div>
</div>
<!-- ============ 3. COVERAGE STATES ============ -->
<div class="step">
<h2>3. Coverage states</h2>
<span class="where">Partial data is the normal case</span>
</div>
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
<div class="cases">
<div class="case">
<h3>4+ years</h3>
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
</div>
<div class="case">
<h3>2–3 years</h3>
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
<strong>"Only 3 years available"</strong> sits under the table.</div>
</div>
<div class="case">
<h3>1 year</h3>
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
the whole story.</div>
</div>
<div class="case">
<h3>Never oversubscribed</h3>
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
in each of the last 6 years, so no distance cut-off was needed.</div>
</div>
<div class="case">
<h3>No data at all</h3>
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
</div>
<div class="case">
<h3>Not distance-ranked</h3>
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
practice, so a distance figure would mislead.</div>
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
</div>
</div>
<!-- ============ 4. MOBILE ============ -->
<div class="step">
<h2>4. Mobile</h2>
<span class="where">≤ 640 px</span>
</div>
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
<div class="phonerow">
<div class="phone">
<div class="phonebody">
<div class="card">
<div class="cardhead" style="margin-bottom:6px">
<h2 class="sectionTitle">Admissions</h2>
</div>
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
<div class="seg" style="margin-bottom:14px">
<button type="button" aria-pressed="true">Year</button>
<button type="button" aria-pressed="false">Trend</button>
<button type="button" aria-pressed="false">Distance</button>
</div>
<dl class="tiles">
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
</dl>
<div class="tile newtile" style="margin-top:8px">
<span class="newflag">New</span>
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
<span class="sub">≈ 500 m · 6 min walk</span></dd>
<dt class="lbl">Last distance offered, 2025</dt>
</div>
</div>
<div class="card">
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
<span class="vic" aria-hidden="true">↓</span>
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
</div>
<div class="chartwrap">
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
<g stroke="#e5dfd5" stroke-width="1">
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
</g>
<g class="axtxt" text-anchor="end" font-size="9">
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
</g>
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
</g>
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
<g class="axtxt" font-size="9" text-anchor="middle">
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
</g>
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
</svg>
</div>
<details class="disclosure" style="margin-top:10px">
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
</details>
</div>
</div>
</div>
<div class="phonenote">
<h3>Mobile decisions</h3>
<ul>
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
</ul>
</div>
</div>
</div>
<script>
// Segmented control on the admissions card swaps the stacked views.
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
btn.addEventListener('click', function () {
var group = btn.closest('.seg');
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
['v-year', 'v-dist'].forEach(function (id) {
document.getElementById(id).hidden = id !== target;
});
});
});
</script>
@@ -0,0 +1,144 @@
/**
* The postcode check.
*
* This is the one place on the site that answers a question about a specific
* family rather than about a school, so the tests here are mostly about what it
* refuses to say: no verdict without a published figure, and no verdict at all
* when the margin is inside the error of a postcode centroid.
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
import { cutoffYearRows, CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
// The panel's logic is independent of it, so the map is stubbed out.
jest.mock('@/components/LeafletCutoffMapInner', () => ({
__esModule: true,
default: () => <div data-testid="cutoff-map" />,
}));
const mockGeocode = jest.fn();
jest.mock('@/lib/api', () => ({
...jest.requireActual('@/lib/api'),
geocodePostcode: (pc: string) => mockGeocode(pc),
}));
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
const d = (year: number, distance_m: number): SchoolAdmissionDistance => ({
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
});
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
function northOf(metres: number) {
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
}
function renderPanel(history: SchoolAdmissionDistance[], admissions: { year: number; oversubscribed?: boolean }[] = []) {
return render(
<CutoffMapPanel schoolInfo={SCHOOL} rows={cutoffYearRows(history, admissions)} />,
);
}
async function check(postcode: string) {
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
}
beforeEach(() => mockGeocode.mockReset());
describe('CutoffMapPanel', () => {
it('renders nothing without a published figure to draw', () => {
const { container } = renderPanel([]);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when the school has no coordinates', () => {
const { container } = render(
<CutoffMapPanel
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
rows={cutoffYearRows([d(2024, 800)])}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it('rejects a malformed postcode without calling the geocoder', async () => {
renderPanel([d(2024, 800)]);
await check('not a postcode');
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
expect(mockGeocode).not.toHaveBeenCalled();
});
it('reports a home clearly inside every published year', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2022, 1000), d(2024, 800)]);
await check('SE23 3NA');
expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/);
});
it('reports a home clearly outside every published year', async () => {
mockGeocode.mockResolvedValue(northOf(5000));
renderPanel([d(2022, 1000), d(2024, 800)]);
await check('SE23 3NA');
expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/);
});
it('declines to call a result that sits inside the measurement error', async () => {
// The home is nominally inside 2024's 800 m cut-off, but only by half the
// uncertainty band — which a postcode centroid cannot resolve.
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
renderPanel([d(2024, 800)]);
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/too close to call/);
expect(result).not.toHaveTextContent(/inside the cut-off in all/);
});
it('counts an unpublished year as unknown rather than as a pass', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2022, 1000), d(2024, 800)], [{ year: 2023, oversubscribed: false }]);
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/inside the cut-off in all 2 years/);
expect(result).toHaveTextContent(/no published figure/);
});
it('surfaces a postcode the geocoder cannot find', async () => {
mockGeocode.mockResolvedValue(null);
renderPanel([d(2024, 800)]);
await check('ZZ99 9ZZ');
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
});
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2024, 800)]);
await check('SE23 3NA');
await screen.findByRole('status');
mockGeocode.mockRejectedValue(new Error('network'));
await check('SE23 3NB');
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
});
it('always states the limits of the check', async () => {
renderPanel([d(2024, 800)]);
// Present before any postcode is entered, not revealed with the answer.
expect(screen.getByText(/An indication only/)).toBeInTheDocument();
// The two caveats divide the work: the map says the rings are not a
// boundary, the check says the measurement may not be a straight line.
expect(screen.getByText(/not a catchment boundary/)).toBeInTheDocument();
expect(screen.getByText(/walking route rather than a straight line/)).toBeInTheDocument();
});
});
@@ -0,0 +1,209 @@
/**
* Last distance offered, rendered on both detail templates.
*
* The figure is the one number on these pages that a parent may act on — it is
* easy to read as "we live inside the catchment, we will get a place". These
* tests pin the things that stop it being read that way: the year is always
* present, the caveat is always present, and the blanket "not available"
* sentence appears only when it is actually true.
*/
import { screen, fireEvent } from '@testing-library/react';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
import type { SchoolAdmissionDistance } from '@/lib/types';
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
year: 2025,
distance_m: 500,
route_count: 1,
la_name: 'Camden',
distance_unit_raw: 'miles',
...over,
});
describe('primary detail page', () => {
it('shows the figure with the year it belongs to', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
});
it('never shows the figure without saying it is not a catchment', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
});
it('flags that a banded school\'s figure is the widest of several routes', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
});
it('renders nothing distance-related when the LA publishes none', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
});
it('carries the figure even with no EES admissions row', () => {
// The two sources are independent; this school has a cut-off and no
// admissions figures. Before this feature the section did not render at all.
renderSchoolDetail({
...primaryFixture,
admissions: null,
admissionsHistory: [],
admissionDistance: cutoff({ distance_m: 1421.05 }),
});
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
});
});
describe('secondary detail page', () => {
it('shows the figure with the year it belongs to', () => {
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
});
it('drops the blanket "not available" line once a distance exists', () => {
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
});
it('names the authority that would hold the data when there is none', () => {
// The old copy asserted "Historical distance cut-off data is not available
// for this school" on every secondary page, including the ones whose
// council does publish it.
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
});
});
// ── The Distance view ──────────────────────────────────────────────────
const history = (pts: [number, number][]): SchoolAdmissionDistance[] =>
pts.map(([year, distance_m]) => ({
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
}));
describe('primary Distance view', () => {
it('appears once there are two or more published years', () => {
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]),
});
expect(screen.getByRole('button', { name: 'Distance' })).toBeInTheDocument();
});
it('stays hidden on a single published year', () => {
// One point is a fact, not a history; a chart of it invites a trend reading
// that is not there.
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2024, 700]]),
});
expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument();
});
it('lists every year in the span, including the ones with no figure', () => {
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
});
// The view is server-rendered but starts hidden behind its tab, and role
// queries skip hidden subtrees — so open it the way a reader would.
fireEvent.click(screen.getByRole('button', { name: 'Distance' }));
// 2022 and 2023 were never published but must still appear as rows, or the
// gap in the chart has nothing explaining it.
for (const year of ['2021', '2022', '2023', '2024']) {
expect(screen.getByRole('rowheader', { name: year })).toBeInTheDocument();
}
expect(screen.getAllByText('Not published').length).toBe(2);
});
it('never claims everyone was offered a place', () => {
// fact_admissions.oversubscribed is about first preferences only, so the
// stronger claim is not supported by the data behind it.
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
admissionsHistory: [
...primaryFixture.admissionsHistory,
{ year: 2022, oversubscribed: false, places_offered: 60 },
],
});
expect(screen.getByText(/Places available on first preferences/)).toBeInTheDocument();
expect(screen.queryByText(/every applicant/i)).not.toBeInTheDocument();
expect(screen.queryByText(/all offered/i)).not.toBeInTheDocument();
});
it('withholds a trend reading while the record is thin', () => {
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2023, 800], [2024, 700]]),
});
expect(screen.getByText(/too few to read as a trend/)).toBeInTheDocument();
expect(screen.queryByText(/Across \d+ published years/)).not.toBeInTheDocument();
});
});
describe('secondary Distance detail', () => {
it('renders inline, with no extra tab, once there are two published years', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
admissionDistance: cutoff({ distance_m: 3472.96 }),
admissionDistanceHistory: history([[2023, 3800], [2024, 3472.96]]),
});
expect(screen.getByText(/Last distance offered, by year/)).toBeInTheDocument();
expect(screen.getByRole('rowheader', { name: '2023' })).toBeInTheDocument();
});
it('explains a selective school by how it admits rather than as missing data', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
admissionDistance: null,
admissionDistanceHistory: [],
});
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
});
it('reads a consistently undersubscribed school as good news', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
admissionDistance: null,
admissionDistanceHistory: [],
admissionsHistory: [
{ year: 2022, oversubscribed: false },
{ year: 2023, oversubscribed: false },
{ year: 2024, oversubscribed: false },
],
});
expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument();
});
});
@@ -0,0 +1,231 @@
/**
* Last distance offered — formatting and the caveats attached to the figure.
*
* The assertions about the route note and the year are not cosmetic. A cut-off
* shown without its year, or a banded school's widest cut-off shown as if it
* were the only one, tells a parent something false about their chances of a
* place — so both are pinned here rather than left to the component.
*/
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
import {
describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote,
describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M,
} from '@/components/school/lastDistanceOffered';
import type { SchoolAdmissionDistance } from '@/lib/types';
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
year: 2025,
distance_m: 500,
route_count: 1,
la_name: 'Camden',
distance_unit_raw: 'miles',
...over,
});
describe('formatCutoffDistance', () => {
it('leads with miles, the unit councils publish in', () => {
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
});
it('switches to kilometres for the support figure above a kilometre', () => {
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('returns null rather than a zero cut-off', () => {
// 0.0 miles appears in the source where a school filled on a higher
// criterion. Rendered as "0.00 miles" it would read as the opposite.
expect(formatCutoffDistance(0)).toBeNull();
expect(formatCutoffDistance(null)).toBeNull();
expect(formatCutoffDistance(undefined)).toBeNull();
expect(formatCutoffDistance(Number.NaN)).toBeNull();
});
});
describe('formatEntryYear', () => {
it('names the intake, not the academic year', () => {
// formatAcademicYear would render 2025 as "2025/26", which reads as a
// school year rather than the September a child started.
expect(formatEntryYear(2025)).toBe('September 2025');
expect(formatEntryYear(null)).toBe('');
});
});
describe('describeCutoff', () => {
it('always carries the entry year alongside the figure', () => {
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
expect(d).not.toBeNull();
expect(d!.primary).toBe('0.48 miles');
expect(d!.entryYear).toBe('September 2024');
});
it('says nothing about routes for a school with one', () => {
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
});
it('warns that a banded school\'s figure is the widest of several', () => {
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
expect(note).toContain('4 admission routes');
expect(note).toContain('shorter cut-off');
});
it('is null when there is nothing publishable', () => {
expect(describeCutoff(null)).toBeNull();
expect(describeCutoff(undefined)).toBeNull();
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
});
});
// ── Year-by-year history ───────────────────────────────────────────────
const d = (year: number, distance_m: number | null, route_count = 1): SchoolAdmissionDistance => ({
year, distance_m, route_count, la_name: 'Camden', distance_unit_raw: 'miles',
});
describe('entryYearOf', () => {
it('reduces EES academic codes and plain entry years to one key', () => {
// Without this the two histories never join and every year looks unpublished.
expect(entryYearOf(202425)).toBe(2024);
expect(entryYearOf(2024)).toBe(2024);
});
});
describe('cutoffYearRows', () => {
it('emits a row for every year in the span, including the empty ones', () => {
const rows = cutoffYearRows([d(2021, 900), d(2024, 700)]);
expect(rows.map((r) => r.year)).toEqual([2024, 2023, 2022, 2021]);
expect(rows.map((r) => r.status)).toEqual([
'published', 'not-published', 'not-published', 'published',
]);
});
it('separates "nothing published" from "was not oversubscribed"', () => {
// The two look identical in the distance data and mean opposite things.
const rows = cutoffYearRows(
[d(2021, 900), d(2023, 700)],
[{ year: 2022, oversubscribed: false }],
);
expect(rows.find((r) => r.year === 2022)!.status).toBe('not-oversubscribed');
});
it('matches EES six-digit years against plain cut-off years', () => {
const rows = cutoffYearRows(
[d(2023, 700)],
[{ year: 202324, places_offered: 60, oversubscribed: true }],
);
expect(rows.find((r) => r.year === 2023)!.placesOffered).toBe(60);
});
it('does not stretch the span back over admissions years with no cut-offs', () => {
// EES reaches back further than councils publish; padding the chart with a
// decade of blanks would bury the years that carry a figure.
const rows = cutoffYearRows(
[d(2024, 700)],
[{ year: 2015, oversubscribed: true }, { year: 2024, oversubscribed: true }],
);
expect(rows.map((r) => r.year)).toEqual([2024]);
});
it('is safe when the backend does not send the field at all', () => {
expect(cutoffYearRows(undefined as never)).toEqual([]);
expect(cutoffYearRows([], [])).toEqual([]);
});
});
describe('cutoffTrendSummary', () => {
const rowsFor = (pts: [number, number][]) => cutoffYearRows(pts.map(([y, m]) => d(y, m)));
it('says nothing below four published points', () => {
expect(cutoffTrendSummary(rowsFor([[2022, 900], [2023, 800], [2024, 700]]))).toBeNull();
});
it('names both endpoints and their years rather than passing a verdict', () => {
const s = cutoffTrendSummary(rowsFor([
[2021, 1000], [2022, 900], [2023, 800], [2024, 500],
]));
expect(s).toContain('2021');
expect(s).toContain('2024');
expect(s).toContain('tightened');
});
it('does not call a small wobble a direction', () => {
const s = cutoffTrendSummary(rowsFor([
[2021, 1000], [2022, 1010], [2023, 990], [2024, 1030],
]));
expect(s).toContain('stayed broadly the same');
});
});
describe('cutoffCoverageNote', () => {
it('warns while the record is too thin to read as a trend', () => {
expect(cutoffCoverageNote(cutoffYearRows([d(2024, 700)]))).toContain('Only 1 year');
expect(cutoffCoverageNote(cutoffYearRows([
d(2021, 1000), d(2022, 900), d(2023, 800), d(2024, 700),
]))).toBeNull();
});
});
describe('describeCutoffAbsence', () => {
it('explains a selective school by how it admits, not by missing data', () => {
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
expect(s).toContain('entrance test');
expect(s).not.toContain('has not published');
});
it('reads a consistently undersubscribed school as good news', () => {
const s = describeCutoffAbsence({
localAuthority: 'Camden',
admissionsHistory: [
{ year: 2022, oversubscribed: false },
{ year: 2023, oversubscribed: false },
{ year: 2024, oversubscribed: false },
],
});
expect(s).toContain('has not needed a distance cut-off');
});
it('otherwise names the authority that would hold the figure', () => {
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
.toContain('Camden has not published');
});
});
describe('compareToCutoffs', () => {
const rows = cutoffYearRows(
[d(2022, 1000), d(2024, 800)],
[{ year: 2023, oversubscribed: false }],
);
it('marks a clearly nearer home as inside every comparable year', () => {
const r = compareToCutoffs(300, rows);
expect(r.insideCount).toBe(2);
expect(r.comparableCount).toBe(2);
expect(r.headline).toContain('inside the cut-off in all 2 years');
});
it('marks a clearly further home as outside', () => {
const r = compareToCutoffs(4000, rows);
expect(r.insideCount).toBe(0);
expect(r.headline).toContain('outside the cut-off in every year');
});
it('refuses to call a result inside the measurement error', () => {
// A postcode centroid covers several addresses; claiming a place on a 20 m
// margin would be inventing precision the inputs do not have.
const r = compareToCutoffs(800 - CUTOFF_UNCERTAINTY_M / 2, rows);
expect(r.years.find((y) => y.year === 2024)!.verdict).toBe('too-close');
expect(r.detail).toContain('too close to call');
});
it('never counts an unpublished year as a pass or a fail', () => {
const r = compareToCutoffs(300, rows);
expect(r.years.find((y) => y.year === 2023)!.verdict).toBe('unknown');
expect(r.detail).toContain('no published figure');
});
});
@@ -60,7 +60,7 @@ describe('buildNavItems', () => {
it('omits sections with no data', () => { it('omits sections with no data', () => {
const flags = computeSchoolFlags(specialFixture); const flags = computeSchoolFlags(specialFixture);
const ids = buildNavItems(flags, { const ids = buildNavItems(flags, {
ofsted: null, admissions: null, yearlyDataLength: 1, ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
}).map((n) => n.id); }).map((n) => n.id);
expect(ids).not.toContain('ofsted'); expect(ids).not.toContain('ofsted');
@@ -74,6 +74,7 @@ describe('buildNavItems', () => {
return buildNavItems(flags, { return buildNavItems(flags, {
ofsted: fixture.ofsted, ofsted: fixture.ofsted,
admissions: fixture.admissions, admissions: fixture.admissions,
admissionDistance: null,
yearlyDataLength: fixture.yearlyData.length, yearlyDataLength: fixture.yearlyData.length,
}).find((n) => n.id === 'results')?.label; }).find((n) => n.id === 'results')?.label;
}; };
@@ -83,11 +84,27 @@ describe('buildNavItems', () => {
expect(label(allThroughFixture)).toBe('Results'); expect(label(allThroughFixture)).toBe('Results');
}); });
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
// 3% of the schools that render have one source and not the other. The nav
// condition and the section's render condition have to agree, or the sticky
// nav links to an anchor that was never rendered.
const flags = computeSchoolFlags(specialFixture);
const ids = buildNavItems(flags, {
ofsted: null,
admissions: null,
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).toContain('admissions');
});
it('keeps the engagement-led ordering', () => { it('keeps the engagement-led ordering', () => {
const flags = computeSchoolFlags(primaryFixture); const flags = computeSchoolFlags(primaryFixture);
const ids = buildNavItems(flags, { const ids = buildNavItems(flags, {
ofsted: primaryFixture.ofsted, ofsted: primaryFixture.ofsted,
admissions: primaryFixture.admissions, admissions: primaryFixture.admissions,
admissionDistance: null,
yearlyDataLength: primaryFixture.yearlyData.length, yearlyDataLength: primaryFixture.yearlyData.length,
}).map((n) => n.id); }).map((n) => n.id);
@@ -127,16 +144,29 @@ describe('buildSecondaryNavItems', () => {
const ids = buildSecondaryNavItems(flags, { const ids = buildSecondaryNavItems(flags, {
ofsted: secondaryFixture.ofsted, ofsted: secondaryFixture.ofsted,
admissions: secondaryFixture.admissions, admissions: secondaryFixture.admissions,
admissionDistance: null,
yearlyDataLength: secondaryFixture.yearlyData.length, yearlyDataLength: secondaryFixture.yearlyData.length,
}).map((n) => n.id); }).map((n) => n.id);
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']); expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
}); });
it('opens the admissions entry for a cut-off distance alone', () => {
const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, {
ofsted: null,
admissions: null,
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).toContain('admissions');
});
it('gates History on more than one year, unlike the primary page', () => { it('gates History on more than one year, unlike the primary page', () => {
const flags = computeSecondaryFlags(secondaryFixture); const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, { const ids = buildSecondaryNavItems(flags, {
ofsted: null, admissions: null, yearlyDataLength: 1, ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
}).map((n) => n.id); }).map((n) => n.id);
expect(ids).not.toContain('history'); expect(ids).not.toContain('history');
@@ -31,6 +31,7 @@ export function renderSchoolDetail(fixture: any) {
const navItems = buildNavItems(flags, { const navItems = buildNavItems(flags, {
ofsted: fixture.ofsted, ofsted: fixture.ofsted,
admissions: fixture.admissions, admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length, yearlyDataLength: fixture.yearlyData.length,
}); });
@@ -44,6 +45,7 @@ export function renderSchoolDetail(fixture: any) {
> >
<PrimarySchoolSections <PrimarySchoolSections
{...fixture} {...fixture}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture} nationalAvg={nationalAveragesFixture}
flags={flags} flags={flags}
/> />
@@ -57,6 +59,7 @@ export function renderSecondarySchoolDetail(fixture: any) {
const navItems = buildSecondaryNavItems(flags, { const navItems = buildSecondaryNavItems(flags, {
ofsted: fixture.ofsted, ofsted: fixture.ofsted,
admissions: fixture.admissions, admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length, yearlyDataLength: fixture.yearlyData.length,
}); });
@@ -70,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
> >
<SecondarySchoolSections <SecondarySchoolSections
{...fixture} {...fixture}
admissionsHistory={fixture.admissionsHistory ?? []}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture} nationalAvg={nationalAveragesFixture}
flags={flags} flags={flags}
/> />
+11 -7
View File
@@ -193,13 +193,17 @@
drawn SVG, so its colours are in the file, not here. What is left is the drawn SVG, so its colours are in the file, not here. What is left is the
ground the artwork sits on and fades into. ground the artwork sits on and fades into.
--hero-ground is sampled from the artwork's own copy area (#FDF9F3), --hero-ground is sampled from the artwork's own copy area, not from
not from Sand. The artwork reserves an empty cream region on the left Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
for the headline, and the scrim that guarantees text contrast fades from seam straight down the hero.
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
darker per channel) leaves a visible seam straight down the hero. */ The artwork's copy area is not one flat colour: it runs from a peach
--hero-ground: #FDF9F3; #FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
--hero-ground-rgb: 253, 249, 243; the left edge is foliage rather than cream. This value is sampled from
the middle of that pale run, where the headline and search actually sit;
the scrim is what covers the foliage further down. */
--hero-ground: #FEF2E1;
--hero-ground-rgb: 254, 242, 225;
/* ── Geometry & motion ────────────────────────────────────────────── /* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly "Soft shapes, rounded corners" — the guideline's geometry is markedly
+7 -1
View File
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound(); notFound();
} }
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data; const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, admission_distance_history, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL // Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -176,6 +176,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
const navInput = { const navInput = {
ofsted: ofsted ?? null, ofsted: ofsted ?? null,
admissions: admissions ?? null, admissions: admissions ?? null,
admissionDistance: admission_distance ?? null,
yearlyDataLength: yearly_data.length, yearlyDataLength: yearly_data.length,
}; };
const primaryNavItems = buildNavItems(primaryFlags, navInput); const primaryNavItems = buildNavItems(primaryFlags, navInput);
@@ -228,6 +229,9 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted={ofsted ?? null} ofsted={ofsted ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
admissionDistanceHistory={admission_distance_history ?? []}
deprivation={deprivation ?? null} deprivation={deprivation ?? null}
finance={finance ?? null} finance={finance ?? null}
nationalAvg={nationalAvg} nationalAvg={nationalAvg}
@@ -249,6 +253,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []} admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
admissionDistanceHistory={admission_distance_history ?? []}
deprivation={deprivation ?? null} deprivation={deprivation ?? null}
finance={finance ?? null} finance={finance ?? null}
nationalAvg={nationalAvg} nationalAvg={nationalAvg}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

+123
View File
@@ -0,0 +1,123 @@
'use client';
/**
* CutoffTrendChart
* The last distance offered, year by year.
*
* Two things this chart deliberately does not do.
*
* It does not join across gaps (`spanGaps: false`). A missing year is usually a
* year the authority published nothing, and a line drawn through it would
* assert a cut-off that was never measured.
*
* It does not plot the non-distance outcomes. A year in which the school was
* not oversubscribed has no distance — that is the point of it — and giving it
* a y-position would put a number on the axis that does not exist. The mockup
* floated such years at the top of the plot; here they are a gap in the line
* and a labelled row in the table underneath, which is where a reason can be
* stated in words rather than implied by a coordinate.
*/
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup';
import { useThemeTokens, alpha } from '@/lib/theme';
import type { CutoffYearRow } from './school/lastDistanceOffered';
import styles from './AdmissionsTrendChart.module.css';
const METRES_PER_MILE = 1609.344;
export default function CutoffTrendChart({ rows }: { rows: CutoffYearRow[] }) {
// rows arrive newest-first; a time axis reads oldest-first.
const axis = [...rows].reverse();
const published = axis.filter((r) => r.distanceM != null);
if (published.length < 2) return null;
const labels = axis.map((r) => String(r.year));
const values: (number | null)[] = axis.map((r) =>
r.distanceM != null ? r.distanceM / METRES_PER_MILE : null,
);
const present = values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0);
const lastIdx = present[present.length - 1];
const numeric = values.filter((v): v is number => v != null);
const lo = Math.min(...numeric);
const hi = Math.max(...numeric);
// Headroom proportional to the spread, with a floor so a flat series does not
// collapse onto a single gridline and read as more precise than it is.
const pad = Math.max(0.05, (hi - lo) * 0.25);
const yMin = Math.max(0, lo - pad);
const yMax = hi + pad;
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card',
);
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
layout: { padding: { top: 8 } },
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: 10,
titleFont: { size: 12 },
bodyFont: { size: 12 },
callbacks: {
label: (ctx) =>
ctx.parsed.y == null ? '' : `Last distance offered: ${ctx.parsed.y.toFixed(2)} miles`,
},
},
},
scales: {
y: {
min: yMin,
max: yMax,
grid: { color: cGrid },
ticks: {
font: { size: 11 },
maxTicksLimit: 5,
callback: (v) => `${Number(v).toFixed(2)} mi`,
},
},
x: {
grid: { display: false },
ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 },
},
},
};
const data = {
labels,
datasets: [
{
label: 'Last distance offered',
data: values,
clip: false as const,
spanGaps: false,
borderColor: cBrand,
backgroundColor: alpha('--brand', 0.10),
borderWidth: 2.5,
tension: 0.3,
fill: true,
pointRadius: values.map((v, i) => (v == null ? 0 : i === lastIdx ? 5 : 3)),
pointBackgroundColor: cBrand,
pointBorderColor: cCard,
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6,
},
],
};
return (
<div className={styles.wrapper}>
<Line data={data} options={options} />
</div>
);
}
@@ -460,6 +460,30 @@
} }
} }
/*
* Narrow phones: the button drops below the input instead of sharing the row.
*
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
* screen it took 48% of the row and left the input 102px of text space for a
* 194px placeholder — the field showed "School name or" and stopped. At 320px
* the input was down to 32px, which is too narrow to read what you are typing.
*
* Wrapping rather than restructuring: the pill stays one element, so it keeps
* its border, shadow and :focus-within ring, and the button gets a full-width
* tap target on the way. Giving the button a 100% flex-basis is what forces
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
*/
@media (max-width: 480px) {
.heroMode .omniBoxContainer {
flex-wrap: wrap;
gap: 0.5rem;
}
.heroMode .searchButton {
flex: 1 1 100%;
width: 100%;
}
}
@media (max-width: 420px) { @media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */ /* Below this the pin costs more room than it earns. */
.heroMode .omniIcon { .heroMode .omniIcon {
+16
View File
@@ -92,6 +92,22 @@
max-width: 34ch; max-width: 34ch;
} }
/*
* Quieter than .description, but by size only — not by contrast.
*
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
* fine margin for decorative text and the wrong one for a disclaimer, whose
* whole job is to be legible to someone checking whether this is a government
* site. --on-sunken-muted is 5.66:1 on the same band.
*/
.independence {
margin: 0;
font-size: var(--step--2);
line-height: 1.6;
color: var(--on-sunken-muted);
max-width: 38ch;
}
.sectionTitle { .sectionTitle {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
+8
View File
@@ -35,6 +35,14 @@ export function Footer() {
<p className={styles.description}> <p className={styles.description}>
Compare primary and secondary schools across England. Compare primary and secondary schools across England.
</p> </p>
{/*
Says once, plainly, what the landing page's value props only
imply: we publish official data, we are not an official body.
Cheap to state and expensive to be wrong about.
*/}
<p className={styles.independence}>
An independent site. Not affiliated with the Department for Education or Ofsted.
</p>
<a <a
href="mailto:contact@schoolcompare.co.uk" href="mailto:contact@schoolcompare.co.uk"
className={styles.link} className={styles.link}
+84 -7
View File
@@ -161,10 +161,22 @@
* page and out-shouts the H1 and the search box. Dimming it in CSS is the * page and out-shouts the H1 and the search box. Dimming it in CSS is the
* whole treatment, and the scrim then fades it into the dark panel instead of * whole treatment, and the scrim then fades it into the dark panel instead of
* into cream. * into cream.
*
* Raising the brightness raises the background under light dark-theme text, so
* this value has a contrast floor, not just a taste range. Measured off
* rendered pixels, sampling background up to 120px past each line:
*
* brightness title body
* 0.52 11.01:1 6.44:1
* 0.75 9.48:1 5.21:1 ← current
*
* Body text is the binding one. Going much above 0.75 walks it toward the
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
* than the artwork being dimmed less.
*/ */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.heroArt img { .heroArt img {
filter: brightness(0.52) saturate(0.72) contrast(1.02); filter: brightness(0.75) saturate(0.72) contrast(1.02);
} }
.heroPanel::before { .heroPanel::before {
background: linear-gradient( background: linear-gradient(
@@ -177,6 +189,43 @@
} }
} }
/*
* The one line in the first person, so it is set apart from the marketing copy
* around it without shouting: display face, a step down in size, and a short
* brand rule instead of a bullet or an emoji.
*
* No italic — Manrope ships no italic in the loaded weights, so font-style
* would be synthesised into a slant. Same reason .heroEmph sets font-style
* back to normal.
*/
.heroByline {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 1.25rem 0 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 500;
font-style: normal;
color: var(--text-secondary);
}
.heroByline::before {
content: '';
flex: 0 0 auto;
width: 1.25rem;
height: 2px;
border-radius: 1px;
background: var(--brand);
}
@media (max-width: 640px) {
.heroByline {
margin-top: 1rem;
font-size: var(--step--2);
}
}
.heroEyebrow { .heroEyebrow {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -253,18 +302,42 @@
* the same decision. * the same decision.
*/ */
@media (max-width: 860px) { @media (max-width: 860px) {
/*
* Artwork above the copy, not below it.
*
* The DOM keeps .heroContent first so the desktop overlay does not depend on
* source order, which left the band stranded under the search on phones —
* reading as a strip stuck on the end rather than as a hero image. `order`
* moves it visually only; it is decorative and aria-hidden, so there is no
* reading order to disturb.
*
* It costs about 136px above the search. Measured on a 667px viewport (the
* shortest phone still in use) the search still lands near 400px, well
* inside the fold.
*/
.heroPanel {
display: flex;
flex-direction: column;
}
.heroContent { .heroContent {
padding: 2rem 1.5rem 1.75rem; padding: 1.75rem 1.5rem 1.75rem;
max-width: none; max-width: none;
} }
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
crop into that throws away enough height to cut the flag off the roof and
the base off the building. 13rem holds it at about 3.8:1, where the
building stays whole. */
.heroArt { .heroArt {
position: static; position: static;
height: 11rem; order: -1;
height: 13rem;
} }
/* The band crop centres the schoolhouse at about 65% across, so squeezing /* The band crop puts the schoolhouse at 73% across — reported by
toward 4.9:1 crops the empty sides rather than the subject. */ scripts/build-hero-images.js, which derives it from the crop box rather
than leaving the two to drift. Squeezing narrower then crops the emptier
left side rather than the subject. */
.heroArt img { .heroArt img {
object-position: 65% center; object-position: 73% center;
} }
/* The scrim exists to protect text sitting on the artwork. Nothing sits on /* The scrim exists to protect text sitting on the artwork. Nothing sits on
it here, and leaving it would wash the band out. */ it here, and leaving it would wash the band out. */
@@ -305,8 +378,12 @@
font-size: 1.75rem; font-size: 1.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
the height steps down here while the width does not. 10rem keeps it near
3.6:1, in line with the rest of the range. */
.heroArt { .heroArt {
height: 8.5rem; height: 10rem;
} }
} }
+14 -2
View File
@@ -157,7 +157,20 @@ const VALUE_PROPS: ValueProp[] = [
{ {
icon: <ShieldCheckIcon />, icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust, tintClass: styles.propIconTrust,
title: 'Official & trusted', /*
* "Built on official data", never "Official".
*
* The previous title was "Official & trusted", whose grammatical subject
* is this site — it reads as a claim that schoolcompare is itself an
* official service. It is not: it is an independent site that republishes
* official figures. The distinction is the difference between describing
* the data and describing ourselves, and only the first is true.
*
* Everywhere else the word appears ("official DfE figures", "the official
* figure isn't in") it already qualifies the data, which is correct and
* should stay.
*/
title: 'Built on official data',
body: 'Every figure comes from DfE performance tables and Ofsted.', body: 'Every figure comes from DfE performance tables and Ofsted.',
}, },
{ {
@@ -450,7 +463,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
geoState={geoState} geoState={geoState}
geoError={geoError} geoError={geoError}
/> />
</div> </div>
{/* {/*
+8 -8
View File
@@ -40,8 +40,8 @@
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)'; const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */ /** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 1672; const WIDE_W = 2000;
const WIDE_H = 941; const WIDE_H = 1125;
export function HeroIllustration() { export function HeroIllustration() {
return ( return (
@@ -51,13 +51,13 @@ export function HeroIllustration() {
<source <source
media="(max-width: 860px)" media="(max-width: 860px)"
type="image/avif" type="image/avif"
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w" srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
media="(max-width: 860px)" media="(max-width: 860px)"
type="image/webp" type="image/webp"
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w" srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
sizes={SIZES} sizes={SIZES}
/> />
{/* {/*
@@ -71,15 +71,15 @@ export function HeroIllustration() {
media query does, and it sits after the modern formats so they still media query does, and it sits after the modern formats so they still
win where supported. win where supported.
*/} */}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" /> <source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
<source <source
type="image/avif" type="image/avif"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w" srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
type="image/webp" type="image/webp"
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w" srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
sizes={SIZES} sizes={SIZES}
/> />
{/* {/*
@@ -90,7 +90,7 @@ export function HeroIllustration() {
*/} */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src="/brand/hero-wide-1200.jpg" src="/brand/hero-wide-1400.jpg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={WIDE_W} width={WIDE_W}
@@ -0,0 +1,169 @@
/**
* LeafletCutoffMapInner
* The cut-off distances drawn around the school.
*
* L.circle takes a radius in metres and projects it properly, which is exactly
* what these rings are: a straight-line distance from the school. (L.circleMarker
* would take pixels and would silently stop meaning anything as the user zoomed.)
*
* The latest year is filled and labelled; earlier years are hairlines behind it,
* so a tightening cut-off reads as a set of shrinking circles without needing a
* legend to decode it. The map auto-fits the widest ring, so the whole history
* is in frame whatever the distances are.
*/
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
export interface CutoffRing {
year: number;
radiusM: number;
latest: boolean;
}
interface Props {
lat: number;
lng: number;
rings: CutoffRing[];
/** The parent's geocoded postcode, once they have entered one. */
home: { lat: number; lng: number } | null;
interactive: boolean;
}
const SCHOOL_PIN = `
<div style="position:relative;width:22px;height:22px">
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
</div>`;
// A square rotated 45° — a different SHAPE from the school's circle, not just a
// different colour, so the two are still distinguishable to anyone who cannot
// separate the hues.
const HOME_PIN = `
<div style="width:18px;height:18px;display:grid;place-items:center">
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
</div>`;
function cssVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
const elRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layersRef = useRef<L.Layer[]>([]);
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
useEffect(() => {
if (!elRef.current || mapRef.current) return;
const map = L.map(elRef.current, {
zoomControl: false,
attributionControl: true,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
touchZoom: false,
}).setView([lat, lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
setTimeout(() => map.invalidateSize(), 60);
return () => {
map.remove();
mapRef.current = null;
layersRef.current = [];
zoomCtrlRef.current = null;
};
}, [lat, lng]);
// Redraw rings, pins and the viewport whenever the data changes.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
layersRef.current.forEach((l) => l.remove());
layersRef.current = [];
const brand = cssVar('--brand', '#0F766E');
const above = cssVar('--status-above', '#2F855A');
// Widest first, so the small recent ring is not buried under the old ones.
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
for (const r of ordered) {
const circle = L.circle([lat, lng], {
radius: r.radiusM,
color: brand,
weight: r.latest ? 3 : 1.5,
opacity: r.latest ? 1 : 0.45,
fill: r.latest,
fillColor: brand,
fillOpacity: r.latest ? 0.12 : 0,
interactive: false,
}).addTo(map);
layersRef.current.push(circle);
}
const school = L.marker([lat, lng], {
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
keyboard: false,
interactive: false,
}).addTo(map);
layersRef.current.push(school);
if (home) {
const pin = L.marker([home.lat, home.lng], {
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
keyboard: false,
interactive: false,
}).addTo(map);
layersRef.current.push(pin);
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
color: above,
weight: 2,
dashArray: '5 5',
opacity: 0.85,
interactive: false,
}).addTo(map);
layersRef.current.push(line);
}
// Frame the widest ring — and the home pin when it falls outside it, so a
// family beyond every cut-off can still see where they sit.
const widest = ordered[0]?.radiusM ?? 500;
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
map.fitBounds(bounds, { padding: [16, 16] });
}, [lat, lng, rings, home]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handlers = [
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
];
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
if (interactive && !zoomCtrlRef.current) {
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
zoomCtrlRef.current.addTo(map);
} else if (!interactive && zoomCtrlRef.current) {
zoomCtrlRef.current.remove();
zoomCtrlRef.current = null;
}
setTimeout(() => map.invalidateSize(), 80);
}, [interactive]);
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
}
+39 -7
View File
@@ -48,13 +48,32 @@
Each bar compares against its own benchmark (expected vs higher standard / Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */ as one line spanning both bars. */
/*
* Knockout, not a colour.
*
* This marker was var(--brand) — the same value as .barExpected and
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
* rendered distinguishably at all. 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.
*
* No single colour fixes this, because the marker's position is data-driven:
* it can land on the bar, on the empty track, or straddle the boundary. So it
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
* the core reads; on the pale track the edge reads. Both tokens flip with the
* theme, so the pairing holds in dark mode too.
*
* The edge is box-shadow rather than border so it costs no layout width and
* cannot shift the 50% translate.
*/
.natTick { .natTick {
position: absolute; position: absolute;
top: -3px; top: -3px;
bottom: -3px; bottom: -3px;
width: 2px; width: 3px;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--brand); background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
border-radius: 2px; border-radius: 2px;
z-index: 4; z-index: 4;
pointer-events: none; pointer-events: none;
@@ -63,13 +82,14 @@
.natTick::before { .natTick::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -3px; top: -4px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 5px; width: 6px;
height: 5px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--brand); background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
} }
.barHeaderRight { .barHeaderRight {
@@ -122,12 +142,24 @@
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94); transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
/*
* Expected and Exceeding were both var(--brand) — one colour for two series,
* distinguished only by which row you were looking at, while the legend
* claimed two.
*
* They are a sequential pair, not two categories: "exceeding" is a subset of
* the same cohort at a harder bar. So they take two steps of the same hue
* rather than two different hues, with the harder measure the more intense
* step. --brand-stronger is darker than --brand in the light theme and lighter
* in the dark one, which is the right direction in both: further from the
* ground.
*/
.barExpected { .barExpected {
background: var(--brand); background: var(--brand);
} }
.barExceeding { .barExceeding {
background: var(--brand); background: var(--brand-stronger);
} }
.barLabel { .barLabel {
+26 -3
View File
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
<SubjectColumn key={subject.name} subject={subject} /> <SubjectColumn key={subject.name} subject={subject} />
))} ))}
</div> </div>
{/*
The legend describes what is drawn, which it previously did not.
Both data swatches were var(--status-above) — green — while the bars
they labelled were var(--brand) teal, and they were identical to each
other, so two different series shared one swatch. Worse, the only
swatch that matched the bar colour was the one labelled "National
average": anyone reading the chart by matching colours would conclude
the teal bars WERE the national average.
Each swatch now carries the exact value its bar carries, and the
national-average swatch mirrors the knockout marker rather than being
a flat colour, so it is recognisable as the thing on the chart.
*/}
<div className={styles.legend}> <div className={styles.legend}>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} /> <div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
Expected standard Expected standard
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} /> <div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
Exceeding / high score Exceeding / high score
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} /> <div
className={styles.legendSwatch}
style={{
background: 'var(--bg-card)',
boxShadow: '0 0 0 1px var(--text-primary)',
width: '3px',
height: '12px',
borderRadius: '2px',
}}
/>
National average National average
</div> </div>
</div> </div>
@@ -12,59 +12,127 @@
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types'; import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils'; import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic'; import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared'; import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle'; import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts'; import { AdmissionsTrendChart } from './charts';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import {
describeCutoff, cutoffYearRows,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function AdmissionsSection({ export function AdmissionsSection({
admissions, admissions,
admissionsHistory, admissionsHistory,
admissionDistance,
admissionDistanceHistory,
schoolInfo,
isAllThrough, isAllThrough,
}: { }: {
admissions: SchoolAdmissions; /* Nullable: the two data sources are independent. A minority of schools have
a published cut-off distance but no EES admissions row (3% of the schools
that render, sampled against staging), and for those this section exists
to carry the distance alone. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School;
isAllThrough: boolean; isAllThrough: boolean;
}) { }) {
const cutoff = describeCutoff(admissionDistance);
// Every year in the span, including the ones with nothing in them — the
// classification of a missing year is what the Distance view is mostly for.
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
const publishedYears = cutoffRows.filter((r) => r.status === 'published').length;
// One published year is a fact, not a history: the tile already says it, and
// a chart of a single point invites a trend reading that is not there.
const showDistanceView = publishedYears >= 2;
// Trend toggle only appears with ≥2 years carrying an offer rate. // Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2; const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of // Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen // first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is. // uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions); const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>; const title = (
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
);
{/* All-through admissions data covers a single entry point (usually the {/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */} read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? ( const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}. {/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p> </p>
) : null; ) : null;
{/* 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. */}
const distanceTile = cutoff && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
<dd className={styles.admissionsTileNum}>
{cutoff.primary}
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
</dd>
<dt className={styles.admissionsTileLabel}>
Last distance offered · {cutoff.entryYear}
</dt>
</div>
);
const distanceNote = cutoff && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
);
/*
* Which row template the tile grid needs.
*
* The grid reserves two equal rows so the year view matches the height of the
* trend chart beside it. With a cut-off and no EES admissions there are no
* metric tiles to fill them, and the reserved rows render as a bare block of
* the grid's own gap colour under the distance tile.
*/
const hasMetricTiles = admissions != null && (
admissions.places_offered != null
|| admissions.first_preference_applications != null
|| admissions.first_preference_offer_pct != null
|| admissions.total_applications != null
);
const tilesClass = [
styles.admissionsTiles,
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
].filter(Boolean).join(' ');
const yearView = ( const yearView = (
<> <>
<dl className={styles.admissionsTiles}> <dl className={tilesClass}>
{admissions.places_offered != null && ( {admissions?.places_offered != null && (
<div className={styles.admissionsTile}> <div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd> <dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt> <dt className={styles.admissionsTileLabel}>Places offered</dt>
</div> </div>
)} )}
{admissions.first_preference_applications != null && ( {admissions?.first_preference_applications != null && (
<div className={styles.admissionsTile}> <div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd> <dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt> <dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div> </div>
)} )}
{admissions.first_preference_offer_pct != null && ( {admissions?.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}> <div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}> <dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? ( {admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
@@ -81,20 +149,25 @@ export function AdmissionsSection({
<dt className={styles.admissionsTileLabel}>Got their first choice</dt> <dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div> </div>
)} )}
{admissions.total_applications != null && ( {admissions?.total_applications != null && (
<div className={styles.admissionsTile}> <div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd> <dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt> <dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div> </div>
)} )}
{distanceTile}
</dl> </dl>
{admissionsSummary.chip && ( {admissionsSummary?.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p> <p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)} )}
{distanceNote}
</> </>
); );
const trendView = ( {/* Guarded rather than asserted: showAdmissionsTrend already requires
admissions, and tying the two together in one expression keeps that
invariant checked by the compiler instead of assumed. */}
const trendView = admissions && (
<> <>
<div className={styles.admissionsChartCap}>First-choice offer rate</div> <div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} /> <AdmissionsTrendChart history={admissionsHistory} />
@@ -109,18 +182,36 @@ export function AdmissionsSection({
</> </>
); );
const distanceView = showDistanceView && (
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} />
);
const views: AdmissionsView[] = [
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
];
if (showAdmissionsTrend) {
views.push({
id: 'trend',
label: `${admissionsHistory.length}-year trend`,
content: trendView,
className: styles.admissionsViewTrend,
});
}
if (showDistanceView) {
views.push({
id: 'distance',
label: 'Distance',
content: distanceView,
className: styles.admissionsViewTrend,
});
}
return ( return (
<Section id="admissions"> <Section id="admissions">
{showAdmissionsTrend ? ( {views.length > 1 ? (
<AdmissionsViewToggle <AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
title={title}
subtitle={subtitle}
trendLabel={`${admissionsHistory.length}-year trend`}
yearView={yearView}
trendView={trendView}
/>
) : ( ) : (
/* No trend data — render statically, with no client component at all. */ /* One view — render statically, with no client component at all. */
<> <>
<div className={styles.admissionsHeader}> <div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2> <h2 className={styles.sectionTitle}>{title}</h2>
@@ -3,14 +3,25 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import styles from './schoolSections.module.css'; import styles from './schoolSections.module.css';
export interface AdmissionsView {
id: string;
/** Button text. "This year" and the "N-year trend" label are asserted by the
* e2e journey, so they are not free to drift. */
label: string;
content: ReactNode;
/** The year view sizes its tile grid to match the chart beside it, so each
* view keeps its own class rather than sharing one wrapper style. */
className?: string;
}
/** /**
* The only interactive part of the primary admissions section, and the only * The only interactive part of the primary admissions section, and the only
* client component in components/school/. * client component in components/school/.
* *
* Both views are always present in the DOM and visibility is toggled with the * Every view is always present in the DOM and visibility is toggled with the
* `hidden` attribute — matching the previous behaviour exactly — so the * `hidden` attribute, so the server-rendered markup passed in as content never
* server-rendered markup passed in as yearView/trendView never ships as client * ships as client JavaScript — this component carries the state and nothing
* JavaScript. * else.
* *
* It spans the header and the viewport because the segmented control sits * It spans the header and the viewport because the segmented control sits
* inside .admissionsHeader beside the <h2> while the viewport is a sibling * inside .admissionsHeader beside the <h2> while the viewport is a sibling
@@ -19,39 +30,38 @@ import styles from './schoolSections.module.css';
export function AdmissionsViewToggle({ export function AdmissionsViewToggle({
title, title,
subtitle, subtitle,
trendLabel, views,
yearView,
trendView,
}: { }: {
title: ReactNode; title: ReactNode;
subtitle: ReactNode; subtitle: ReactNode;
trendLabel: string; views: AdmissionsView[];
yearView: ReactNode;
trendView: ReactNode;
}) { }) {
const [view, setView] = useState<'year' | 'trend'>('year'); const [active, setActive] = useState(views[0]?.id);
return ( return (
<> <>
<div className={styles.admissionsHeader}> <div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2> <h2 className={styles.sectionTitle}>{title}</h2>
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view"> <div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}> {views.map((v) => (
This year <button
</button> key={v.id}
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}> type="button"
{trendLabel} aria-pressed={active === v.id}
</button> onClick={() => setActive(v.id)}
>
{v.label}
</button>
))}
</div> </div>
</div> </div>
{subtitle} {subtitle}
<div className={styles.admissionsViewport}> <div className={styles.admissionsViewport}>
<div className={styles.admissionsViewYear} hidden={view !== 'year'}> {views.map((v) => (
{yearView} <div key={v.id} className={v.className} hidden={active !== v.id}>
</div> {v.content}
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}> </div>
{trendView} ))}
</div>
</div> </div>
</> </>
); );
@@ -0,0 +1,70 @@
/**
* CutoffDistanceDetail — the full year-by-year cut-off story.
*
* Chart, table, map and postcode check, in that order: the shape of the trend,
* then what happened in each year, then the same numbers over real streets,
* then the reader's own address measured against them.
*
* Shared by both templates because the content is identical, but placed
* differently by each. The primary page has a segmented control and gives this
* its own "Distance" tab; the secondary page is one flat panel by design, so it
* renders inline beneath the metric cards. Extracting it keeps the caveats and
* the ordering from drifting apart between the two.
*
* Server component — the map and postcode form are the only client parts, and
* they carry their own boundary.
*/
import type { School } from '@/lib/types';
import { sectionStyles as styles } from './sectionShared';
import { CutoffTrendChart } from './charts';
import { CutoffYearTable } from './CutoffYearTable';
import { CutoffMapPanel } from './CutoffMapPanel';
import {
cutoffTrendSummary, cutoffCoverageNote,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
type CutoffYearRow,
} from './lastDistanceOffered';
export function CutoffDistanceDetail({
rows,
schoolInfo,
/** Repeat the "not a catchment" caveat here. On the primary page this view is
* behind a tab, so a reader can reach it without ever seeing the tile that
* carries the caveat; on the secondary page the caveat is already directly
* above, and repeating it verbatim reads as a stutter. */
showCaveat = true,
}: {
rows: CutoffYearRow[];
schoolInfo: School;
showCaveat?: boolean;
}) {
const publishedYears = rows.filter((r) => r.status === 'published').length;
if (publishedYears < 2) return null;
const trendSummary = cutoffTrendSummary(rows);
const coverageNote = cutoffCoverageNote(rows);
return (
<>
<div className={styles.admissionsChartCap}>Last distance offered, by year</div>
<CutoffTrendChart rows={rows} />
<p className={styles.cutoffChartKey}>
The line breaks where no figure was published — it is never drawn across
a gap. The table below says what happened in each of those years.
</p>
{trendSummary && <p className={styles.admissionsTrendSummary}>{trendSummary}</p>}
<CutoffYearTable rows={rows} />
{coverageNote && <p className={styles.cutoffChartKey}>{coverageNote}</p>}
<CutoffMapPanel schoolInfo={schoolInfo} rows={rows} />
{showCaveat && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
</p>
)}
</>
);
}
@@ -0,0 +1,205 @@
'use client';
/**
* CutoffMapPanel — "Where the last place went".
*
* The rings and the postcode check live in one component because they are one
* question asked twice: the map answers "how far is that?" and the form answers
* "how far are we?". Entering a postcode drops a pin on the same rings rather
* than producing a separate verdict somewhere else on the page.
*
* The postcode never leaves the browser except to postcodes.io for a lat/long,
* and nothing is stored — this is a client-side measurement, not a lookup
* against the family.
*/
import { useState, useMemo, type FormEvent } from 'react';
import dynamic from 'next/dynamic';
import type { School } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils';
import {
compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT,
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered';
import type { CutoffRing } from '../LeafletCutoffMapInner';
import styles from './schoolSections.module.css';
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
ssr: false,
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
});
const VERDICT_MARK: Record<CutoffVerdict, string> = {
inside: '✓',
outside: '✕',
'too-close': '≈',
unknown: '–',
};
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
inside: 'Inside that year’s cut-off',
outside: 'Outside that year’s cut-off',
'too-close': 'Too close to the cut-off to say',
unknown: 'No published figure for this year',
};
// Written out rather than composed from the verdict string. A computed
// `styles[...]` key silently yields undefined when a class is renamed, and an
// unstyled "outside" chip would look exactly like an "inside" one.
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
inside: styles.cutoffYearInside,
outside: styles.cutoffYearOutside,
'too-close': styles.cutoffYearTooClose,
unknown: styles.cutoffYearUnknown,
};
export function CutoffMapPanel({
schoolInfo,
rows,
}: {
schoolInfo: School;
rows: CutoffYearRow[];
}) {
const [postcode, setPostcode] = useState('');
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
const [result, setResult] = useState<CutoffCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const lat = schoolInfo.latitude;
const lng = schoolInfo.longitude;
const rings: CutoffRing[] = useMemo(() => {
const published = rows.filter((r) => r.distanceM != null);
return published.map((r, i) => ({
year: r.year,
radiusM: r.distanceM as number,
// rows are newest-first, so the first published row is the current year.
latest: i === 0,
}));
}, [rows]);
// Without coordinates there is nothing to draw rings around and nothing to
// measure a postcode against.
if (lat == null || lng == null || rings.length === 0) return null;
const onCheck = async (e: FormEvent) => {
e.preventDefault();
const value = postcode.trim();
if (!value) return;
if (!isValidPostcode(value)) {
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
setResult(null);
setHome(null);
return;
}
setBusy(true);
setError(null);
try {
const point = await geocodePostcode(value);
if (!point) {
setError('We could not find that postcode. Check it and try again.');
setResult(null);
setHome(null);
return;
}
setHome({ lat: point.latitude, lng: point.longitude });
// calculateDistance returns kilometres; everything here is metres.
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
setResult(compareToCutoffs(metres, rows));
} catch {
setError('Something went wrong looking up that postcode. Try again in a moment.');
setResult(null);
setHome(null);
} finally {
setBusy(false);
}
};
const latestRing = rings[0];
return (
<div className={styles.cutoffMapPanel}>
<h3 className={styles.cutoffMapTitle}>Where the last place went</h3>
<div className={styles.cutoffMapFigure}>
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
</div>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
{latestRing.year} cut-off
</li>
{rings.length > 1 && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
</li>
)}
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
<p className={styles.cutoffMapCaveat}>{CUTOFF_MAP_CAVEAT}</p>
<div className={styles.cutoffCheck}>
<h4 className={styles.cutoffCheckTitle}>How far away are you?</h4>
<p className={styles.cutoffCheckSub}>
We measure a straight line from your postcode to the school and compare
it with each year’s cut-off. Your postcode is not stored.
</p>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
<input
id="cutoff-postcode"
type="text"
inputMode="text"
autoComplete="postal-code"
spellCheck={false}
placeholder="e.g. SE23 3NA"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
className={styles.cutoffCheckInput}
/>
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
{busy ? 'Checking…' : 'Check'}
</button>
</form>
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
{result && (
<div className={styles.cutoffCheckResult} role="status">
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
<ul className={styles.cutoffYearStrip}>
{[...result.years].reverse().map((y) => (
<li
key={y.year}
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
title={VERDICT_TITLE[y.verdict]}
>
<span className={styles.cutoffYearChipYear}>{y.year}</span>
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
</li>
))}
</ul>
</div>
)}
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
/**
* CutoffYearTable — the last distance offered, year by year.
*
* The table is where the chart's gaps get explained. Each year says which of
* the three things happened, in words, because "no figure" alone is the least
* useful thing we could tell a parent about a year.
*
* Server component: it is static markup over data the page already holds.
*/
import type { CutoffYearRow, CutoffYearStatus } from './lastDistanceOffered';
import { sectionStyles as styles } from './sectionShared';
const STATUS_LABEL: Record<CutoffYearStatus, string> = {
published: 'Oversubscribed',
// Precisely what the EES field supports, and no more: first preferences did
// not exceed places. It does not establish that every applicant was offered
// a place, so it must not be worded as though it did.
'not-oversubscribed': 'Places available on first preferences',
'not-published': 'Not published',
};
const STATUS_CLASS: Record<CutoffYearStatus, string> = {
published: styles.cutoffPillOver,
'not-oversubscribed': styles.cutoffPillOk,
'not-published': styles.cutoffPillNa,
};
const DISTANCE_TEXT: Record<CutoffYearStatus, string> = {
published: '',
'not-oversubscribed': 'No cut-off needed',
'not-published': '—',
};
export function CutoffYearTable({ rows }: { rows: CutoffYearRow[] }) {
if (rows.length === 0) return null;
const showPlaces = rows.some((r) => r.placesOffered != null);
return (
<div className={styles.cutoffTableWrap}>
<table className={styles.cutoffTable}>
<caption className={styles.srOnly}>
Last distance offered by year, with the reason where no figure was published
</caption>
<thead>
<tr>
<th scope="col">Year</th>
<th scope="col">Last distance</th>
{showPlaces && <th scope="col">Places</th>}
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.year} className={i === 0 ? styles.cutoffRowLatest : undefined}>
<th scope="row">{r.year}</th>
<td className={r.status === 'published' ? undefined : styles.cutoffCellMissing}>
{r.status === 'published' ? r.figure : DISTANCE_TEXT[r.status]}
{r.status === 'published' && (r.routeCount ?? 1) > 1 && (
<span className={styles.cutoffRouteFlag}>
{' '}furthest of {r.routeCount}
</span>
)}
</td>
{showPlaces && (
<td className={r.placesOffered == null ? styles.cutoffCellMissing : undefined}>
{r.placesOffered ?? '—'}
</td>
)}
<td>
<span className={`${styles.cutoffPill} ${STATUS_CLASS[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -13,7 +13,7 @@
import type { import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils'; import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections'; import type { SchoolFlags } from '@/lib/schoolSections';
@@ -34,6 +34,8 @@ export interface PrimarySchoolSectionsProps {
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null; deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null; finance: SchoolFinance | null;
nationalAvg: NationalAverages | null; nationalAvg: NationalAverages | null;
@@ -42,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({ export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census, schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags, admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) { }: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {}; const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -91,10 +94,17 @@ export function PrimarySchoolSections({
/> />
)} )}
{admissions && ( {/* Either source is enough to justify the section. The cut-off distance
and the EES admissions figures come from different places and a
minority of schools have one without the other — gating on admissions
alone would hide a published distance on those pages. */}
{(admissions || admissionDistance) && (
<AdmissionsSection <AdmissionsSection
admissions={admissions} admissions={admissions}
admissionsHistory={admissionsHistory} admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
schoolInfo={schoolInfo}
isAllThrough={flags.isAllThrough} isAllThrough={flags.isAllThrough}
/> />
)} )}
@@ -6,17 +6,30 @@
* JavaScript at all. Server component. * JavaScript at all. Server component.
*/ */
import type { School, SchoolAdmissions } from '@/lib/types'; import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils'; import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared'; import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import {
describeCutoff, cutoffYearRows, describeCutoffAbsence,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({ export function SecondaryAdmissionsSection({
admissions, schoolInfo, hasSixthForm, admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
schoolInfo, hasSixthForm,
}: { }: {
admissions: SchoolAdmissions; /* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School; schoolInfo: School;
hasSixthForm: boolean; hasSixthForm: boolean;
}) { }) {
const cutoff = describeCutoff(admissionDistance);
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
// Moved with this section from SecondarySchoolDetailView, its only consumer. // Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => { const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -40,32 +53,43 @@ export function SecondaryAdmissionsSection({
)} )}
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{admissions.places_offered != null && ( {admissions?.places_offered != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div> <div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div> <div className={styles.metricValue}>{admissions.places_offered}</div>
</div> </div>
)} )}
{admissions.total_applications != null && ( {admissions?.total_applications != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div> <div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div> <div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div> </div>
)} )}
{admissions.first_preference_applications != null && ( {admissions?.first_preference_applications != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div> <div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div> <div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div> </div>
)} )}
{admissions.first_preference_offer_pct != null && ( {admissions?.first_preference_offer_pct != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div> <div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div> <div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div> </div>
)} )}
{cutoff && (
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
<div className={styles.metricLabel}>
Last distance offered · {cutoff.entryYear}
</div>
<div className={styles.metricValue}>
{cutoff.primary}
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
</div>
</div>
)}
</div> </div>
{admissions.oversubscribed != null && ( {admissions?.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}> <div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed {admissions.oversubscribed
? '⚠ Applications exceeded places last year' ? '⚠ Applications exceeded places last year'
@@ -73,9 +97,29 @@ export function SecondaryAdmissionsSection({
</div> </div>
)} )}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}> {/* Replaces a blanket "distance cut-off data is not available for this
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details. school", which was hardcoded onto every secondary page and was untrue
</p> wherever the local authority does publish. The absence is now stated
only when it is real, and names the authority that would hold it. */}
{cutoff ? (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
) : (
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
{describeCutoffAbsence({
localAuthority: schoolInfo.local_authority,
admissionsPolicy: schoolInfo.admissions_policy,
admissionsHistory,
})}
</p>
)}
{/* Inline rather than behind a tab: this template is one flat panel by
design and has no segmented control to hang a third view on. The
caveat is suppressed because it is already directly above. */}
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} showCaveat={false} />
{hasSixthForm && ( {hasSixthForm && (
<div className={styles.sixthFormNote}> <div className={styles.sixthFormNote}>
@@ -13,7 +13,7 @@
import type { import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils'; import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections'; import type { SecondaryFlags } from '@/lib/schoolSections';
@@ -32,6 +32,11 @@ export interface SecondarySchoolSectionsProps {
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
/** Needed to tell a year with no published cut-off apart from a year the
* school simply was not oversubscribed. */
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null; deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null; finance: SchoolFinance | null;
nationalAvg: NationalAverages | null; nationalAvg: NationalAverages | null;
@@ -40,7 +45,8 @@ export interface SecondarySchoolSectionsProps {
export function SecondarySchoolSections({ export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census, schoolInfo, yearlyData, ofsted, census,
admissions, deprivation, finance, nationalAvg, flags, admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) { }: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -79,9 +85,14 @@ export function SecondarySchoolSections({
/> />
)} )}
{admissions && ( {/* See PrimarySchoolSections: distance and EES admissions are independent
sources, so either one warrants the section. */}
{(admissions || admissionDistance) && (
<SecondaryAdmissionsSection <SecondaryAdmissionsSection
admissions={admissions} admissions={admissions}
admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo} schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm} hasSixthForm={flags.hasSixthForm}
/> />
+5
View File
@@ -24,3 +24,8 @@ export const AdmissionsTrendChart = dynamic(
() => import('../AdmissionsTrendChart'), () => import('../AdmissionsTrendChart'),
{ ssr: false }, { ssr: false },
); );
export const CutoffTrendChart = dynamic(
() => import('../CutoffTrendChart'),
{ ssr: false },
);
@@ -0,0 +1,377 @@
/**
* Shared logic and copy for the "last distance offered" figure.
*
* The primary and secondary admissions sections lay their metrics out
* differently (a seamless tile grid vs. metric cards), so the markup is not
* shared — but the words are. Every caveat below is doing a specific job, and
* a figure that appeared on one template without them would be misleading in a
* way the other template was not:
*
* * The year, because a cut-off is the outcome of one particular admissions
* round and means nothing detached from it.
* * "not a fixed catchment", because that is the inference a parent will
* otherwise draw, and it is wrong — the distance moves every year.
* * The route count, because on a banded school the headline is the widest
* of several cut-offs and does not apply to every child.
*/
import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */
primary: string;
/** Supporting conversion, e.g. "500 m". */
secondary: string;
/** Entry point the figure belongs to, e.g. "September 2025". */
entryYear: string;
/** Present only where several admission routes were collapsed into one figure. */
routeNote: string | null;
}
/** What the cut-off measures, and what it does not. Identical on both templates. */
export const CUTOFF_NOTE =
'The furthest home offered a place, after higher priorities such as siblings, ' +
'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' +
'with the number of applications.';
/** Straight-line, because that is how councils measure it. */
export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.';
export function describeCutoff(
distance: SchoolAdmissionDistance | null | undefined
): CutoffDisplay | null {
if (!distance) return null;
const figure = formatCutoffDistance(distance.distance_m);
if (!figure) return null;
const routes = distance.route_count ?? 1;
return {
primary: figure.primary,
secondary: figure.secondary,
entryYear: formatEntryYear(distance.year),
routeNote:
routes > 1
? `Furthest of ${routes} admission routes at this school — the one that ` +
'applies to your child may have had a shorter cut-off.'
: null,
};
}
// ---------------------------------------------------------------------------
// Year-by-year history
// ---------------------------------------------------------------------------
/**
* Why a year has no cut-off figure.
*
* The distinction matters more than the figure does. A missing year is not one
* fact but three, and collapsing them into "no data" throws away the most
* reassuring case (the school had places for everyone who put it first) and
* the most important caveat (nobody published anything, so we cannot say).
*/
export type CutoffYearStatus =
/** The authority published a cut-off for this year. */
| 'published'
/**
* No cut-off published, and EES shows fewer first-preference applications
* than places offered. Note the precise claim: this says the school was not
* oversubscribed ON FIRST PREFERENCES, which is what the data supports. It
* does NOT establish that every applicant was offered a place — total
* applications can still exceed places — so no copy here may say so.
*/
| 'not-oversubscribed'
/** Nothing published, and nothing in the admissions data to explain why. */
| 'not-published';
export interface CutoffYearRow {
year: number;
status: CutoffYearStatus;
/** Straight-line metres, present only when status is 'published'. */
distanceM: number | null;
/** Formatted figure, present only when status is 'published'. */
figure: string | null;
placesOffered: number | null;
routeCount: number | null;
}
interface AdmissionsYearLike {
year: number;
places_offered?: number | null;
oversubscribed?: boolean | null;
}
/**
* EES years are six-digit academic codes (202425); cut-off years are plain
* entry years (2024). Both denote the same September intake, so they have to be
* reduced to a common key before they can be matched.
*/
export function entryYearOf(year: number): number {
const s = String(year);
return s.length === 6 ? Number(s.slice(0, 4)) : year;
}
/**
* One row per year across the full span the school has any record for, newest
* first. Years with no record at all still appear — an unbroken axis is what
* makes a gap legible as a gap.
*/
export function cutoffYearRows(
/* Defaulted, not merely optional: admission_distance_history is an additive
API field, so a frontend deployed ahead of its backend receives undefined
here. Defaulting keeps that a section that renders nothing rather than a
page that throws. */
history: SchoolAdmissionDistance[] = [],
admissionsHistory: AdmissionsYearLike[] = [],
): CutoffYearRow[] {
const published = new Map<number, SchoolAdmissionDistance>();
for (const d of history ?? []) {
if (d.distance_m != null) published.set(entryYearOf(d.year), d);
}
const admissions = new Map<number, AdmissionsYearLike>();
for (const a of admissionsHistory ?? []) admissions.set(entryYearOf(a.year), a);
const years = [...published.keys(), ...admissions.keys()];
if (years.length === 0) return [];
// The span is bounded by the cut-off data, not by admissions: EES reaches
// back further than councils publish, and padding the chart with a decade of
// "not published" would bury the years that carry a figure.
const publishedYears = [...published.keys()];
const from = publishedYears.length ? Math.min(...publishedYears) : Math.min(...years);
const to = Math.max(...years);
const rows: CutoffYearRow[] = [];
for (let y = to; y >= from; y -= 1) {
const d = published.get(y);
const a = admissions.get(y);
let status: CutoffYearStatus = 'not-published';
if (d) status = 'published';
else if (a?.oversubscribed === false) status = 'not-oversubscribed';
rows.push({
year: y,
status,
distanceM: d?.distance_m ?? null,
figure: d ? formatCutoffDistance(d.distance_m)?.primary ?? null : null,
placesOffered: a?.places_offered ?? null,
routeCount: d?.route_count ?? null,
});
}
return rows;
}
/**
* A factual summary of how the cut-off has moved.
*
* Deliberately not a verdict. It names both endpoints and their years and lets
* the reader draw the conclusion, because the series is short, gappy, and
* driven by things outside the school's control — one large sibling cohort
* moves it. Withheld below four published points, where a swing between two
* years is noise wearing the clothes of a trend.
*/
export function cutoffTrendSummary(rows: CutoffYearRow[]): string | null {
const pts = rows.filter((r) => r.status === 'published' && r.distanceM != null);
if (pts.length < 4) return null;
// rows are newest-first
const latest = pts[0];
const earliest = pts[pts.length - 1];
const a = formatCutoffDistance(earliest.distanceM)?.primary;
const b = formatCutoffDistance(latest.distanceM)?.primary;
if (!a || !b) return null;
const change = latest.distanceM! - earliest.distanceM!;
// A tenth of the earlier figure — below that the endpoints are effectively
// the same and calling it a direction would be reading noise.
const meaningful = Math.abs(change) > earliest.distanceM! * 0.1;
const direction = !meaningful
? 'has stayed broadly the same'
: change < 0
? 'has tightened'
: 'has widened';
return `Across ${pts.length} published years the cut-off ${direction}: `
+ `${a} in ${earliest.year}, ${b} in ${latest.year}.`;
}
// ---------------------------------------------------------------------------
// "Would we have got in?"
// ---------------------------------------------------------------------------
/**
* How close a home has to be to a year's cut-off before the comparison stops
* meaning anything, in metres.
*
* A UK postcode unit covers roughly fifteen addresses and postcodes.io returns
* its centroid, so the home point carries error of this order before anything
* else is considered. Against a cut-off that is often only 500 m, that is a
* fifth of the whole distance. Inside this band the honest answer is that we
* cannot tell, and saying "you would have been offered a place" would be
* inventing precision the inputs do not have.
*/
export const CUTOFF_UNCERTAINTY_M = 100;
export type CutoffVerdict = 'inside' | 'outside' | 'too-close' | 'unknown';
export interface CutoffYearComparison {
year: number;
status: CutoffYearStatus;
verdict: CutoffVerdict;
}
export interface CutoffCheckResult {
/** Straight-line metres from the given postcode to the school. */
distanceM: number;
distanceLabel: string;
years: CutoffYearComparison[];
/** Years the home is clearly inside, out of those with a published figure. */
insideCount: number;
comparableCount: number;
headline: string;
detail: string;
}
export function compareToCutoffs(
distanceM: number,
rows: CutoffYearRow[],
): CutoffCheckResult {
const years: CutoffYearComparison[] = rows.map((r) => {
if (r.status !== 'published' || r.distanceM == null) {
return { year: r.year, status: r.status, verdict: 'unknown' as const };
}
const margin = r.distanceM - distanceM;
const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
return { year: r.year, status: r.status, verdict };
});
const comparable = years.filter((y) => y.verdict !== 'unknown');
const inside = comparable.filter((y) => y.verdict === 'inside');
const tooClose = comparable.filter((y) => y.verdict === 'too-close');
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
let headline: string;
if (comparable.length === 0) {
headline = `${label} from the school`;
} else if (inside.length === comparable.length) {
headline = `${label} away — inside the cut-off in all ${comparable.length} `
+ `${comparable.length === 1 ? 'year' : 'years'} with a published figure`;
} else if (inside.length === 0 && tooClose.length === 0) {
headline = `${label} away — outside the cut-off in every year with a published figure`;
} else {
headline = `${label} away — inside the cut-off in ${inside.length} of `
+ `${comparable.length} years with a published figure`;
}
const parts: string[] = [];
if (tooClose.length > 0) {
parts.push(
`${tooClose.length} ${tooClose.length === 1 ? 'year is' : 'years are'} too close to call: `
+ 'your postcode is a centroid covering several addresses, so a margin under '
+ `${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`,
);
}
const unknown = years.length - comparable.length;
if (unknown > 0) {
parts.push(`${unknown} ${unknown === 1 ? 'year has' : 'years have'} no published figure to compare against.`);
}
return {
distanceM,
distanceLabel: label,
years,
insideCount: inside.length,
comparableCount: comparable.length,
headline,
detail: parts.join(' '),
};
}
/**
* The limits of the check, stated where a parent will act on it.
*
* Every clause is load-bearing. Distance is the last criterion applied, not the
* first; the authority's own measurement may be a walking route rather than a
* straight line, which is always longer for the same home; and a past cut-off
* constrains next year's not at all.
*/
export const CUTOFF_CHECK_CAVEAT =
'An indication only. Places go first to children in care, EHCP places, '
+ 'siblings and — at faith schools — on faith criteria, so a home inside the '
+ 'distance can still miss out. Some authorities measure a walking route '
+ 'rather than a straight line, which is always longer for the same home, and '
+ "next year's cut-off depends on next year's applicants. Always check the "
+ "school's own admissions policy.";
/**
* The rings are a drawing of a number, not a boundary anyone administers.
*
* The walking-route caveat belongs to CUTOFF_CHECK_CAVEAT immediately below
* this on the page, which states it more usefully ("always longer for the same
* home"). Saying it in both places read as a stutter.
*/
export const CUTOFF_MAP_CAVEAT =
'Each ring is the straight-line cut-off for that year, drawn around the '
+ 'school. It illustrates the distance — it is not a catchment boundary.';
// ---------------------------------------------------------------------------
// When there is no figure
// ---------------------------------------------------------------------------
interface AbsenceInput {
localAuthority?: string | null;
admissionsPolicy?: string | null;
admissionsHistory?: AdmissionsYearLike[];
}
/**
* Why this school has no cut-off distance, in the most useful terms available.
*
* "No data" is the least informative thing we could say, and for two of these
* cases it is also the most pessimistic reading of a fact that is either
* neutral or good news. The order matters: a selective school's absence is
* explained by how it admits, which outranks anything the publication record
* says.
*/
export function describeCutoffAbsence({
localAuthority,
admissionsPolicy,
admissionsHistory = [],
}: AbsenceInput): string {
const policy = (admissionsPolicy ?? '').toLowerCase();
if (policy.includes('selective')) {
return 'Places at this school are ranked by the entrance test rather than by '
+ 'distance, so no cut-off distance applies.';
}
const known = admissionsHistory.filter((a) => a.oversubscribed != null);
if (known.length >= 3 && known.every((a) => a.oversubscribed === false)) {
return `First preferences have not exceeded places in any of the last ${known.length} `
+ 'years, so this school has not needed a distance cut-off.';
}
return (localAuthority
? `${localAuthority} has not published a cut-off distance for this school.`
: 'No cut-off distance has been published for this school.')
+ ' Contact the admissions authority for its oversubscription criteria.';
}
/**
* A short note on how thin the record is.
*
* Four points is the same threshold the trend summary uses: below it the series
* is too short to carry a direction, and saying so is more useful than leaving
* the reader to count the rows.
*/
export function cutoffCoverageNote(rows: CutoffYearRow[]): string | null {
const published = rows.filter((r) => r.status === 'published').length;
if (published === 0 || published >= 4) return null;
return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, `
+ 'which is too few to read as a trend.';
}
@@ -778,6 +778,38 @@
word-break: break-word; word-break: break-word;
} }
/* Last distance offered, secondary template. These cards are opaque
(--bg-secondary), so the tint can be set directly here — unlike the primary
tile grid, nothing shows through between them. */
.metricCardDistance {
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
border-color: var(--brand);
/* Two columns' worth of width. Its label carries the entry year as well as
the measure, which at the 140px track width of the other cards wraps to
three lines and pushes the figure out of alignment with them. */
grid-column: span 2;
}
.metricCardDistance .metricValue {
flex-direction: column;
gap: 0.1rem;
color: var(--brand-strong);
}
.metricValueSub {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
/* Same measured reason as .admissionsTileDistance above: --text-muted falls to
4.09:1 on the tinted card in dark theme. */
.metricCardDistance .metricLabel,
.metricCardDistance .metricValueSub {
color: var(--text-secondary);
}
.metricHint { .metricHint {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-muted); color: var(--text-muted);
@@ -1453,6 +1485,62 @@
color: var(--brand-strong); color: var(--brand-strong);
} }
/* ── Admissions: last distance offered ──
*
* Spans the full grid width instead of taking a fifth half-cell. Two reasons:
* the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap;
* and this is the figure the section is most often opened for, which a
* half-width cell would rank below "Applied in total".
*/
.admissionsTileDistance {
grid-column: 1 / -1;
/*
* --brand-bg is translucent, and the tile grid paints --border between
* cells, so a plain `background` would let the hairline colour show through
* and read as muddy rather than tinted. The opaque card colour is laid down
* first and the tint composited over it as an image.
*/
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
}
.admissionsTileDistance .admissionsTileNum {
color: var(--brand-strong);
}
/*
* The tint costs the muted tokens their AA margin, so this tile does not use
* them.
*
* Measured on the composited backdrop, not the computed one: the tint is a
* background-image over --bg-card, so getComputedStyle reports the untinted
* card and flatters the result. Sampling the rendered pixel in dark theme gives
* a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5
* floor — while --text-secondary is 6.50:1.
*
* The label is where the year lives, so it is the last thing on the tile that
* can afford to be hard to read: a distance without its year is not a fact
* about anything.
*/
.admissionsTileDistance .admissionsTileLabel,
.admissionsTileDistance .admissionsTileSub {
color: var(--text-secondary);
}
/*
* The caveats, not a footnote in the visual-noise sense: without them the
* figure reads as a catchment boundary. Kept at body contrast rather than
* --text-muted for that reason — it qualifies the number above it, so it has
* to be as readable as the number.
*/
.admissionsDistanceNote {
margin: 0.85rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
.admissionsVerdict { .admissionsVerdict {
margin-top: 0.75rem; margin-top: 0.75rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -1568,6 +1656,20 @@
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
} }
/* The distance tile occupies a third row. Left implicit it would inherit the
1fr sizing of the row template and stretch to match the metric tiles above,
which are twice its content height. */
.admissionsViewYear .admissionsTilesWithDistance {
grid-template-rows: 1fr 1fr auto;
}
/* Cut-off distance but no EES admissions figures: there is nothing to put in
the two metric rows, and leaving them in the template paints an empty block
of the grid's gap colour beneath the tile. */
.admissionsViewYear .admissionsTilesDistanceOnly {
grid-template-rows: auto;
}
.admissionsChartCap { .admissionsChartCap {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
@@ -1807,12 +1909,23 @@
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
transition: width 0.6s ease; transition: width 0.6s ease;
} }
/*
* Knockout, for the same reason as .natTick in SatsChart.module.css: this was
* var(--brand), identical to .att8VizFill above it, so where the marker
* crossed the school's own bar it measured 1.00:1 — invisible for exactly the
* schools at or above the national average.
*
* A light core with a dark edge reads on the teal fill and on the pale track
* alike, and both tokens flip with the theme. Keep this in step with
* SatsChart's .natTick; they are the same marker on two templates.
*/
.att8VizNatLine { .att8VizNatLine {
position: absolute; position: absolute;
top: -4px; top: -4px;
bottom: -4px; bottom: -4px;
width: 2px; width: 3px;
background: var(--brand); background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
border-radius: 2px; border-radius: 2px;
z-index: 2; z-index: 2;
} }
@@ -1951,3 +2064,383 @@
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; } .secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; } .secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
} }
/* ══════════════════════════════════════════════════════════════════════
Last distance offered — the Distance view, map and postcode check
══════════════════════════════════════════════════════════════════════ */
/* Visually hidden but still read aloud. The year strip encodes its verdict as
a glyph, so every chip carries a spelled-out equivalent here — a screen
reader announcing "2025 ✓" tells you nothing. */
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.cutoffChartKey {
margin: 0.6rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
/* ── Year-by-year table ── */
.cutoffTableWrap {
margin-top: 1.25rem;
overflow-x: auto;
}
.cutoffTable {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
/* Below this the four columns crush; the wrapper scrolls instead of the
page, so the section itself never scrolls sideways. */
min-width: 26rem;
font-variant-numeric: tabular-nums;
}
.cutoffTable thead th {
text-align: right;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
padding: 0 0.625rem 0.55rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cutoffTable thead th:first-child,
.cutoffTable tbody th {
text-align: left;
}
.cutoffTable tbody td,
.cutoffTable tbody th {
text-align: right;
padding: 0.65rem 0.625rem;
border-bottom: 1px solid var(--border);
font-weight: 400;
color: var(--text-primary);
}
.cutoffTable tbody th {
font-weight: 600;
}
.cutoffTable tbody tr:last-child td,
.cutoffTable tbody tr:last-child th {
border-bottom: none;
}
.cutoffRowLatest td,
.cutoffRowLatest th {
background: var(--bg-secondary);
}
.cutoffCellMissing {
color: var(--text-secondary);
}
.cutoffRouteFlag {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
}
.cutoffPill {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.cutoffPillOver {
background: var(--brand-bg);
color: var(--brand-strong);
}
.cutoffPillOk {
background: var(--status-above-bg);
color: var(--status-above);
}
.cutoffPillNa {
background: var(--bg-secondary);
color: var(--text-secondary);
}
/* ── Map panel ── */
.cutoffMapPanel {
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.cutoffMapTitle {
margin: 0 0 0.85rem;
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.cutoffMapFigure {
height: 320px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.cutoffMapSkeleton {
width: 100%;
height: 100%;
background: var(--bg-secondary);
}
.cutoffMapLegend {
list-style: none;
margin: 0.85rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.cutoffMapLegend li {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.cutoffSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
flex: none;
}
.cutoffSwatchNow {
background: var(--brand-bg);
box-shadow: inset 0 0 0 2px var(--brand);
}
.cutoffSwatchPast {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--brand);
opacity: 0.5;
}
.cutoffSwatchHome {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--status-above);
transform: rotate(45deg);
}
.cutoffMapCaveat {
margin: 0.75rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
/* ── Postcode check ── */
.cutoffCheck {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
}
.cutoffCheckTitle {
margin: 0 0 0.25rem;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
}
.cutoffCheckSub {
margin: 0 0 0.85rem;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 62ch;
}
.cutoffCheckForm {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.cutoffCheckInput {
font: inherit;
font-size: 1rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
text-transform: uppercase;
/* Grows into the row but never past it — at 320px a fixed width would push
the button out of the viewport. */
flex: 1 1 9rem;
/* Stops well short of full width on desktop, where a postcode field
spanning the whole card reads as a search box. */
max-width: 16rem;
min-width: 0;
}
.cutoffCheckInput::placeholder {
text-transform: none;
color: var(--text-secondary);
}
.cutoffCheckInput:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 1px;
border-color: var(--brand);
}
.cutoffCheckButton {
font: inherit;
font-weight: 600;
font-size: 1rem;
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius-md);
background: var(--brand);
color: var(--brand-on);
cursor: pointer;
flex: none;
transition: background var(--transition);
}
.cutoffCheckButton:hover:not(:disabled) {
background: var(--brand-strong);
}
.cutoffCheckButton:disabled {
opacity: 0.65;
cursor: progress;
}
.cutoffCheckButton:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cutoffCheckError {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--status-below);
}
.cutoffCheckResult {
margin-top: 0.9rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
}
.cutoffCheckHeadline {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.35;
color: var(--text-primary);
text-wrap: balance;
}
.cutoffCheckDetail {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
}
.cutoffYearStrip {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.cutoffYearChip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cutoffYearChipYear {
font-weight: 700;
}
.cutoffYearInside {
background: var(--status-above-bg);
color: var(--status-above);
}
.cutoffYearOutside {
background: var(--status-below-bg);
color: var(--status-below);
}
/* Deliberately neutral rather than a warning colour: "we cannot tell" is not a
bad outcome, and tinting it amber would read as one. */
.cutoffYearTooClose {
background: var(--bg-secondary);
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--border);
}
.cutoffYearUnknown {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.cutoffCheckCaveat {
margin: 0.9rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
@media (max-width: 640px) {
.cutoffMapFigure {
height: 260px;
}
}
+8 -5
View File
@@ -9,7 +9,7 @@
import type { import type {
School, SchoolResult, AbsenceData, SchoolCensus, School, SchoolResult, AbsenceData, SchoolCensus,
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance, OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
} from './types'; } from './types';
import { isSpecialSchool } from './utils'; import { isSpecialSchool } from './utils';
@@ -119,6 +119,9 @@ export function computeSchoolFlags({
export interface NavItemsInput { export interface NavItemsInput {
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
/** A published cut-off distance renders the Admissions section on its own,
* so it has to open the nav entry too. */
admissionDistance: SchoolAdmissionDistance | null;
yearlyDataLength: number; yearlyDataLength: number;
} }
@@ -133,7 +136,7 @@ export interface NavItemsInput {
*/ */
export function buildNavItems( export function buildNavItems(
flags: SchoolFlags, flags: SchoolFlags,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput, { ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
): NavItem[] { ): NavItem[] {
const navItems: NavItem[] = []; const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -143,7 +146,7 @@ export function buildNavItems(
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs', label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
}); });
} }
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
@@ -213,12 +216,12 @@ export function computeSecondaryFlags({
*/ */
export function buildSecondaryNavItems( export function buildSecondaryNavItems(
flags: SecondaryFlags, flags: SecondaryFlags,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput, { ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
): NavItem[] { ): NavItem[] {
const navItems: NavItem[] = []; const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' }); if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
+32
View File
@@ -163,6 +163,30 @@ export interface SchoolAdmissions {
} }
/**
* Last distance offered — how far from the school the last child to be given a
* place that year lived.
*
* Not a catchment: it is the outcome of one year's applications and moves every
* year. Only published by the local authorities that choose to, so most schools
* have none of this at all.
*/
export interface SchoolAdmissionDistance {
/** Entry year the cut-off belongs to — always shown alongside the figure. */
year: number;
/** Straight-line distance in metres. */
distance_m: number | null;
/**
* Admission routes collapsed into this figure (ability bands, or separate
* reception and junior intakes). Above 1 the distance is the furthest of
* several, which the page has to say out loud.
*/
route_count: number | null;
la_name: string | null;
/** The unit the council published in ("miles", "meters", "km"). */
distance_unit_raw: string | null;
}
export interface SchoolDeprivation { export interface SchoolDeprivation {
lsoa_code: string | null; lsoa_code: string | null;
idaci_score: number | null; idaci_score: number | null;
@@ -330,6 +354,14 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */ /** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[]; admissions_history: SchoolAdmissions[];
/** Latest published cut-off distance, or null where the LA publishes none. */
admission_distance: SchoolAdmissionDistance | null;
/**
* Every published cut-off year, oldest first. Sparse by nature — a school may
* have 2021 and 2026 and nothing between, so consumers must classify the
* missing years rather than joining the points they do have.
*/
admission_distance_history: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null; deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null; finance: SchoolFinance | null;
} }
+48
View File
@@ -502,6 +502,54 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
} }
} }
const METRES_PER_MILE = 1609.344;
/**
* Format an admission cut-off distance.
*
* 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.
*
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
* the two swap round and metres lead instead.
*/
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`,
secondary: metres < 1000
? `${Math.round(metres / 10) * 10} m`
: `${(metres / 1000).toFixed(1)} km`,
};
}
/**
* The entry point a cut-off belongs to, phrased the way councils phrase it.
*
* The distance year is a plain entry year (2025 = the September 2025 intake),
* not the six-digit academic year EES uses, so formatAcademicYear would render
* it as "2025/26" and invite the reader to think of it as a school year rather
* than an application round.
*/
export function formatEntryYear(year: number | null | undefined): string {
if (year == null) return '';
return `September ${year}`;
}
export function formatAcademicYear(year: number | null | undefined): string { export function formatAcademicYear(year: number | null | undefined): string {
if (year == null) return ''; if (year == null) return '';
const s = year.toString(); const s = year.toString();
+1 -1
View File
@@ -24,7 +24,7 @@ The landing hero illustration: a path climbing through hills to a schoolhouse,
with an empty cream area on the left reserved for the headline. with an empty cream area on the left reserved for the headline.
- **Source:** supplied by the project owner - **Source:** supplied by the project owner
- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so - **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 PNG), committed so
the derivatives can be regenerated the derivatives can be regenerated
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these - **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
- **Licence:** **to be confirmed by the project owner** - **Licence:** **to be confirmed by the project owner**
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+23 -12
View File
@@ -45,14 +45,24 @@ const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
const OUT = path.join(__dirname, '..', 'public', 'brand'); const OUT = path.join(__dirname, '..', 'public', 'brand');
/* /*
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540, * Band crop, in source pixels, measured against the artwork rather than
* y 300–520, so this keeps it whole with headroom above the flag and enough * guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
* hillside below to read as landscape rather than as a cutout. * up at y 330.
*
* The crop is flush to the right edge because it has to be. The school sits at
* about 82% across the frame, so there is only ~140px of artwork to its right
* — not enough to centre it. Anchoring right and taking 1050px back puts the
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
* the CSS so the narrowest band crops the empty left side rather than the
* subject.
*/ */
const BAND = { left: 700, top: 260, width: 972, height: 380 }; const BAND = { left: 656, top: 300, width: 1344, height: 420 };
const WIDE_WIDTHS = [1672, 1200, 900]; /** Where the schoolhouse lands in the band crop, for CSS object-position. */
const BAND_WIDTHS = [972, 700, 500]; const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
const WIDE_WIDTHS = [2000, 1400, 1000];
const BAND_WIDTHS = [1344, 900, 600];
async function emit(pipeline, name, width) { async function emit(pipeline, name, width) {
const base = pipeline.clone().resize({ width, withoutEnlargement: true }); const base = pipeline.clone().resize({ width, withoutEnlargement: true });
@@ -67,25 +77,26 @@ async function main() {
const meta = await sharp(SRC).metadata(); const meta = await sharp(SRC).metadata();
console.log(`source ${meta.width}×${meta.height}`); console.log(`source ${meta.width}×${meta.height}`);
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
const wide = sharp(SRC); const wide = sharp(SRC);
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w); for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a // Fallback for the <img> itself. Flattened onto the artwork's own cream so a
// JPEG (no alpha) can never composite against black. // JPEG (no alpha) can never composite against black.
await sharp(SRC) await sharp(SRC)
.resize({ width: 1200 }) .resize({ width: 1400 })
.flatten({ background: '#FDF9F3' }) .flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1200.jpg')); .toFile(path.join(OUT, 'hero-wide-1400.jpg'));
const band = sharp(SRC).extract(BAND); const band = sharp(SRC).extract(BAND);
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w); for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
await sharp(SRC) await sharp(SRC)
.extract(BAND) .extract(BAND)
.resize({ width: 700 }) .resize({ width: 900 })
.flatten({ background: '#FDF9F3' }) .flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-700.jpg')); .toFile(path.join(OUT, 'hero-band-900.jpg'));
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort(); const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
let total = 0; let total = 0;
+65
View File
@@ -224,3 +224,68 @@ with DAG(
) )
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
# ── Annual DAG (Last distance offered) ────────────────────────────────
with DAG(
dag_id="school_data_annual_distance",
default_args=default_args,
description="Last distance offered (LA admission cut-offs) extraction and transform",
# Councils publish on allocation day (March for secondary, April for
# primary) and each one on its own timetable, so there is no date worth
# scheduling against. Triggered manually after a collection run refreshes
# the CSV.
schedule=None,
start_date=datetime(2025, 1, 1),
catchup=False,
tags=["school-compare", "annual"],
) as annual_distance_dag:
extract_distance = BashOperator(
task_id="extract_distance",
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres",
)
# Coverage is the thing that silently rots here: the CSV is assembled by
# hand from council publications, so a collection run that half-failed
# produces a valid file with a fraction of the schools in it. A row count
# alone would not catch that — losing an entire local authority leaves the
# total looking healthy — so the floor is checked on distinct LAs too.
validate_distance = BashOperator(
task_id="validate_distance",
bash_command=f"""
cd {PIPELINE_DIR} && python -c "
import psycopg2, os, sys
conn = psycopg2.connect(
host=os.environ.get('PG_HOST', 'localhost'),
port=os.environ.get('PG_PORT', '5432'),
user=os.environ.get('PG_USER', 'postgres'),
password=os.environ.get('PG_PASSWORD', 'postgres'),
dbname=os.environ.get('PG_DATABASE', 'school_compare'),
)
cur = conn.cursor()
cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered')
rows, urns, las = cur.fetchone()
conn.close()
print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities')
if rows < 7000 or urns < 3000 or las < 45:
print('ERROR: distance extract is short of expected coverage '
'(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr)
sys.exit(1)
print('Validation passed')
"
""",
)
dbt_build_distance = BashOperator(
task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+",
)
invalidate_cache_distance = BashOperator(
task_id="invalidate_cache",
bash_command=INVALIDATE_CACHE_CMD,
)
extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance
+15
View File
@@ -67,6 +67,21 @@ plugins:
pip_url: ./plugins/extractors/tap-uk-idaci pip_url: ./plugins/extractors/tap-uk-idaci
executable: tap-uk-idaci executable: tap-uk-idaci
# Last distance offered — admission cut-offs collected from LA
# publications. There is no national source for this, so the URL points at
# the collected CSV rather than at a government endpoint (same pattern as
# the legacy KS2/KS4 URLs above).
- name: tap-uk-school-distance
namespace: uk_school_distance
pip_url: ./plugins/extractors/tap-uk-school-distance
executable: tap-uk-school-distance
settings:
- name: download_url
kind: string
description: URL of the collected last-distance-offered CSV
config:
download_url: http://10.0.1.224:8081/filebrowser/api/public/dl/UU_oEqVr?inline=true
loaders: loaders:
- name: target-postgres - name: target-postgres
variant: meltanolabs variant: meltanolabs
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "tap-uk-school-distance"
version = "0.1.0"
description = "Singer tap for last-distance-offered admission cut-offs collected from LA admissions publications"
requires-python = ">=3.10"
dependencies = [
"singer-sdk~=0.53",
"requests>=2.31",
]
[project.scripts]
tap-uk-school-distance = "tap_uk_school_distance.tap:TapUKSchoolDistance.cli"
@@ -0,0 +1 @@
"""tap-uk-school-distance: Singer tap for LA "last distance offered" admission cut-offs."""
@@ -0,0 +1,135 @@
"""Singer tap for "last distance offered" school admission cut-offs.
Unlike every other tap in this project, the upstream here is not a single
government endpoint. There is no national dataset of admission cut-off
distances: each local authority publishes its own, in its own format
(PDF booklets, XLSX allocation tables, HTML pages), and only some publish
at all. The CSV this tap reads is the output of that collection work —
one row per school per year per admission route, already normalised to
metres in `distance_m`.
The tap deliberately does no cleaning. Implausible values (0.0 miles,
1-metre cut-offs, a 533-mile outlier) are loaded verbatim into `raw` and
filtered in `stg_school_distance`, so the raw table stays a faithful
record of what the councils published and the plausibility rules live in
one reviewable place.
"""
from __future__ import annotations
import csv
import io
import requests
from singer_sdk import Stream, Tap
from singer_sdk import typing as th
REQUEST_TIMEOUT = 300
class SchoolDistanceStream(Stream):
"""Stream: last distance offered, one row per school × year × admission route."""
name = "school_distance_offered"
# school_name is part of the key, not decoration: where a school admits
# through several routes the route is encoded in the name ("… Band 1",
# "… Band 2"), and (urn, year) alone is not unique. Verified against the
# source: (urn, year) collides on 118 keys, (urn, year, school_name) on
# none. Without school_name in the key a reload would silently drop the
# other bands.
primary_keys = ["urn", "year", "school_name"]
replication_key = None
schema = th.PropertiesList(
th.Property("urn", th.IntegerType, required=True),
th.Property("year", th.IntegerType, required=True),
th.Property("school_name", th.StringType, required=True),
th.Property("la_code", th.IntegerType),
th.Property("la_name", th.StringType),
# The figure as the council published it, before unit conversion —
# kept so a surprising metre value can be traced back to "0.31 miles"
# without reopening the source PDF.
th.Property("distance_value_raw", th.NumberType),
th.Property("distance_unit_raw", th.StringType),
th.Property("distance_m", th.NumberType),
# Path of the council publication the row was read from. This is the
# provenance trail for a figure we cannot re-derive from an API.
th.Property("source_file", th.StringType),
).to_dict()
@staticmethod
def _num(value: str) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _int(value: str) -> int | None:
try:
return int(float(value))
except (TypeError, ValueError):
return None
def get_records(self, context):
url = self.config["download_url"]
self.logger.info("Downloading school distance data from %s", url)
resp = requests.get(url, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
reader = csv.DictReader(io.StringIO(resp.text))
yielded = 0
skipped = 0
for row in reader:
urn = self._int(row.get("urn"))
year = self._int(row.get("year"))
name = (row.get("school_name") or "").strip()
# Every part of the primary key must be present, or the load is
# not idempotent. One row in the source has a blank year.
if urn is None or year is None or not name:
skipped += 1
continue
yield {
"urn": urn,
"year": year,
"school_name": name,
"la_code": self._int(row.get("la_code")),
"la_name": (row.get("la_name") or "").strip() or None,
"distance_value_raw": self._num(row.get("distance_value_raw")),
"distance_unit_raw": (row.get("distance_unit_raw") or "").strip() or None,
"distance_m": self._num(row.get("distance_m")),
"source_file": (row.get("source_file") or "").strip() or None,
}
yielded += 1
self.logger.info(
"Yielded %d distance records (%d skipped: missing urn, year or school name)",
yielded,
skipped,
)
class TapUKSchoolDistance(Tap):
"""Singer tap for LA-published admission cut-off distances."""
name = "tap-uk-school-distance"
config_jsonschema = th.PropertiesList(
th.Property(
"download_url",
th.StringType,
required=True,
description="URL of the collected last-distance-offered CSV",
),
).to_dict()
def discover_streams(self):
return [SchoolDistanceStream(self)]
if __name__ == "__main__":
TapUKSchoolDistance.cli()
@@ -156,6 +156,22 @@ models:
- name: cross_la_offers - name: cross_la_offers
- name: total_offers - name: total_offers
- name: fact_admission_distance
description: >
Last distance offered — one row per URN per year. Where a school admits
through several routes (ability bands, separate reception/junior
intakes) the figure is the furthest of them and route_count says how
many were collapsed.
columns:
- name: urn
tests: [not_null]
- name: year
tests: [not_null]
- name: distance_m
tests: [not_null]
- name: route_count
tests: [not_null]
- name: fact_finance - name: fact_finance
description: School financial data — one row per URN per year description: School financial data — one row per URN per year
columns: columns:
@@ -0,0 +1,50 @@
-- Mart: last distance offered — one row per URN per year.
--
-- Collapses the per-route grain of stg_school_distance to the grain the site
-- serves. Where a school admits through several routes the figure shown is the
-- FURTHEST of them, which is what "last distance offered" literally means: the
-- distance of the last place the school gave out that year.
--
-- route_count travels with it so the page can say so. A parent looking at a
-- banded secondary needs to know that 2.4 miles is the widest of four bands
-- and not the number that applies to their own child — the count is what makes
-- that sentence possible, and dropping it would make the figure quietly
-- optimistic.
--
-- Kept separate from fact_admissions rather than joined into it. That mart is
-- EES-derived and near-complete for England; this one comes from 57 local
-- authorities that publish cut-offs at all, so folding it in would add mostly
-- null columns to every school in the country and tie two very different
-- refresh cadences together.
with routes as (
select * from {{ ref('stg_school_distance') }}
),
per_year as (
select
urn,
year,
max(distance_m) as distance_m,
count(*) as route_count,
min(la_code) as la_code,
min(la_name) as la_name,
-- The unit the council actually published in. Where routes disagree
-- (they do not in the current data) the alphabetically first is taken
-- rather than an arbitrary row, so the mart is deterministic.
min(distance_unit_raw) as distance_unit_raw,
min(source_file) as source_file
from routes
group by urn, year
)
select
urn,
year,
distance_m,
route_count,
la_code,
la_name,
distance_unit_raw,
source_file
from per_year
@@ -61,3 +61,11 @@ sources:
- name: idaci - name: idaci
description: Income Deprivation Affecting Children Index lookups description: Income Deprivation Affecting Children Index lookups
- name: school_distance_offered
description: >
Last distance offered (admission cut-offs) transcribed from local
authority admissions publications. One row per school × year ×
admission route; no national source exists, so coverage is limited to
the LAs that publish. Loaded verbatim — implausible values are
filtered in stg_school_distance, not here.
@@ -0,0 +1,55 @@
-- Staging model: "last distance offered" admission cut-offs.
--
-- One row per school × year × admission route. A school admits through
-- several routes when it bands by ability (Islington publishes four bands per
-- secondary) or takes more than one intake (Gloucestershire publishes
-- reception, junior and secondary allocations separately). The route is
-- carried in school_name, which is why it forms part of the grain here and is
-- collapsed only in fact_admission_distance.
--
-- PLAUSIBILITY BAND (25 m – 25 km)
--
-- The source is transcribed from council PDFs, spreadsheets and web pages, and
-- a minority of rows are transcription or unit errors rather than real
-- cut-offs. Three failure modes, all present:
--
-- * 0.0 miles (34 rows, mostly Staffordshire). Published where a school
-- filled on a higher criterion so distance was never reached — a real
-- fact, but "0 metres" read as a cut-off would tell a parent the opposite
-- of the truth.
-- * 1–10 metre values (72 rows, e.g. Hillingdon). A cut-off inside the
-- school gate is not physically possible; these are unit slips.
-- * Values above 25 km (~240 rows), topping out at 533 miles. Genuine rural
-- secondary cut-offs reach roughly 12–15 miles, so the ceiling keeps those
-- while cutting figures that are visibly wrong.
--
-- Together these are ~4% of rows. They are loaded verbatim into raw and
-- excluded here, so the rule is reviewable in one place and the raw table
-- stays a faithful record of what each council published.
{% set min_plausible_m = 25 %}
{% set max_plausible_m = 25000 %}
with source as (
select * from {{ source('raw', 'school_distance_offered') }}
),
renamed as (
select
cast(urn as integer) as urn,
cast(year as integer) as year,
trim(school_name) as admission_route,
cast(la_code as integer) as la_code,
la_name,
cast(distance_value_raw as numeric) as distance_value_raw,
distance_unit_raw,
cast(distance_m as numeric) as distance_m,
source_file
from source
where urn is not null
and year is not null
and distance_m is not null
and cast(distance_m as numeric) between {{ min_plausible_m }} and {{ max_plausible_m }}
)
select * from renamed
@@ -0,0 +1,27 @@
-- Custom test: every published cut-off distance is inside the plausibility
-- band, and there is exactly one row per school per year.
--
-- The band is enforced in stg_school_distance; this asserts it survived the
-- max() collapse in the mart. A failure means either the staging filter was
-- edited without updating this test, or the grain broke — both of which put a
-- visibly wrong number ("533 miles") on a live school page, which is the one
-- outcome this feature cannot afford.
with out_of_band as (
select urn, year, distance_m, 'out of plausibility band' as problem
from {{ ref('fact_admission_distance') }}
where distance_m is null
or distance_m < 25
or distance_m > 25000
),
duplicated as (
select urn, year, max(distance_m) as distance_m, 'duplicate urn/year' as problem
from {{ ref('fact_admission_distance') }}
group by urn, year
having count(*) > 1
)
select * from out_of_band
union all
select * from duplicated