029fe8d8a61d0de595796268705e8fd564d4da11
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cd6a45bf7d |
refactor: rename similar → nearby, so the code says what the section does
The section ranks on distance and is headed "Other schools nearby", but every identifier still called it "similar" — the exact drift that leaves a later reader trusting a name over the behaviour. Mechanical: files, the module, the payload key, the type, the components, the prop. No behaviour change; the suites are unchanged in count and still green. Free to do now because #150 has not merged, so the payload key rename needs no lockstep deploy. Uses of "similar" that are ordinary English — progress measures compared to similar pupils, and unrelated comments — are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2175dccb7c |
fix(api): treat "16 plus" as secondary, the way PHASE_GROUPS already does
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 9s
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
GIAS phase 6 is "16 plus", and PHASE_GROUPS deliberately files it in the secondary group. The payload helper decided the same question with `"secondary" in phase_text`, which that value does not satisfy — so a sixth-form college was handed the primary bucket and offered infant schools as its peers, with the KS2 metric key to label them. No crash; just a page confidently showing the wrong schools. The decision now lives in similar_schools.is_secondary_phase, beside the PHASE_GROUPS bucket it selects from, so the two cannot drift again. A test pins them together. The same binary assumption had a second output. computeSchoolFlags tests for the substring too, so a 16-plus school renders the primary template, and the composer was labelling the section from the template: "Other primary schools near <sixth form college>" above a row of secondaries. The section now derives its noun from the school's own phase, which also removes the duplicated wording from both composers. A 16-plus school's candidates span the whole secondary group, so no single noun fits and it gets the honest general one. Reported in review on #150. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4e0d8bcf87 |
feat(web): render similar schools on both detail templates
Inside SchoolDetailShell rather than after it, because the sticky nav's scroll-spy finds sections with getElementById and can only reach one that lives in the shell. Last in the order, and last in the nav, because the two must agree or the nav links to an anchor that was never rendered. hasSimilarSchools is optional on NavItemsInput, matching hasLocation beside it: absent has to mean "no section", and making it required would have churned ten unrelated call sites for no added safety. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c9a1892bfb |
feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.
The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.
Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.
What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.
Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.
One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.
The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
94151c58ea |
fix(admissions): move the cut-off detail into its own section
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page is a section with a nav entry, and "how close did we need to live, and would we have got in?" is a topic, not a variant reading of the intake figures. The headline number stays on the Admissions tile where the intake story is; the record behind it now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
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 |
||
|
|
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
|
||
|
|
752eb07310 |
refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.
The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.
Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.
Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
"Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed
The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|