dc156058fe33a8fdd0eacb42e69005093089bf4d
861
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
576013d627 |
docs(destinations): design for KS4 and post-16 destination measures
The published files suppress individual cells, not whole cohorts, and the categories sum to the cohort — so on 22% of mainstream secondaries the withheld figure can be recovered by subtraction. Three disclosure rules fall out of that, and the rest of the design is downstream of them. Verified against the EES API rather than assumed: both datasets carry school-level rows keyed by URN, with the disadvantage split and every destination category the display needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob |
||
|
|
7c08138fe4 |
Merge pull request 'fix(map): the popup never took the dark theme' (#136) from fix/dark-mode-map-popup-contrast into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m41s
Reviewed-on: #136 |
||
|
|
a7829d591a |
fix(map): the popup never took the dark theme
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
leaflet.css paints `background: white; color: #333` on the popup card and its tip. LeafletMapInner binds themed content into it — the school name and the headline figure are var(--text-primary) — so in dark mode #E9EEF0 landed on #FFFFFF at 1.17:1. The two things the popup exists to say were the two least readable things on the page. Every other foreground in that popup failed too, from the same cause: the muted phase line at 2.90:1, the vs-national delta at 1.94:1, the Ofsted badge at 1.74:1. Moving the surface onto --bg-card fixes all of them at once — 13.52, 5.45, 8.14 and 9.11:1 respectively. In light mode --bg-card is #FFFFFF, so the popup renders exactly as it did. globals.css already pulls the rest of Leaflet's chrome onto the tokens, and says why: "this matters most in dark mode, where Leaflet's white attribution bar would otherwise sit on a near-black page." The popup was simply missed. The View Details button needed its own fix. It pairs background:var(--status- above) with a literal white label, which theming the card does not reach: --status-above is #36743F in light but #7FCB8A in dark, taking the label from 5.63:1 to 1.94:1. --text-inverse is the token for ink on a saturated fill, and the popup's own Ofsted badge already uses it. darkThemeSafety already guards this defect class, but only inside .module.css. Neither half of this one lives there — the surface is a third party's, the text is inline in a TSX template — so it scanned clean throughout. Two rules added for the layer it could not see. Fixing the grouped-selector blind spot in its rules() helper was needed to write them: taking only a selector's last line discarded every selector in a grouped rule but the final one, which makes a safety guard fail open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM |
||
|
|
1ed4470fc2 |
Merge pull request 'fix(admissions): flag-off pages must not speak for the council' (#135) from fix/distance-flag-off-absence-copy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m43s
Reviewed-on: #135 |
||
|
|
7a16b1b52f |
fix(admissions): flag-off pages must not speak for the council
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m8s
The secondary admissions section words the absence of a cut-off distance:
"<LA> has not published a cut-off distance for this school." That sentence
is true when the authority publishes nothing. It is false when the
authority does publish and the admission_distance flag is simply off — and
off is the current state, so every secondary page with an EES admissions
row has been making a claim about a council on our behalf.
The backend already draws the distinction the copy needs. /api/schools/{urn}
omits the admission_distance key entirely while the flag is dark rather than
sending null, precisely so that "we are not publishing cut-offs" stays
distinguishable from "this school has no cut-off"; lib/types.ts says so in
as many words. The page then collapsed the two with `?? null` before the
section ever saw them.
So stop collapsing it: thread the raw field to SecondarySchoolSections and
word the absence only when the feature is on. Null still gets the sentence
naming the authority — that case is unchanged and still tested.
Primary pages are unaffected: AdmissionsSection carries no absence copy and
renders nothing when there is no figure. DistanceSection already treated
absent and null alike; only its type widens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
|
||
|
|
cf9d41b476 |
Merge pull request 'fix(analytics): the funnel source read a referrer that never changes' (#134) from fix/navigation-source-soft-nav into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m40s
Reviewed-on: #134 |
||
|
|
e820e7fecd |
fix(analytics): the funnel source read a referrer that never changes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m13s
The staging E2E gate has been red since #132 merged (run 1064, and 1066 after it): "a school reached from a location page is attributed to it, not to direct" expects `place`, receives `direct`. #132 fixed a real bug — `/schools/` had no case and fell through to `direct` — but the mechanism underneath it never worked. getNavigationSource read document.referrer, which the browser writes only when a *document* loads. Every internal navigation here is an App Router soft navigation: history.pushState, no new document, so document.referrer goes on naming whatever opened the tab for the whole session. Verified on staging: load /schools/brentwood, click a school, the URL becomes /school/… and document.referrer is still "". So `from` reported `direct` for essentially every in-app journey, not just the ones through the location layer — search, rankings, compare and detail were all being counted as "typed the URL". The unit suite passed throughout because every case set document.referrer directly, which only happens on a full page load. The fix is a module-level trail written by RouteTrail, a render-nothing client component in the root layout. Its lifetime is exactly right: it survives soft navigation, and it dies on a real document load — which is precisely when document.referrer becomes meaningful again, so the two cover each other with no overlap. Reading it skips entries equal to the current path rather than taking the second-to-last. That makes the answer independent of whether the layout effect or the page effect ran first — React orders those by tree position, which is not a contract worth resting a measurement on — and it gives the right answer both when the user returns to a page they came from and on a hard load of a school page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM |
||
|
|
4fdeb70a93 |
Merge pull request 'feat(places): say what each school is, not only how it scored' (#133) from feat/place-school-attributes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m42s
Reviewed-on: #133 |
||
|
|
9a1f56c431 |
feat(places): say what each school is, not only how it scored
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m44s
The location tables carried one column: a percentage. A parent shortlisting from a town page is asking a different question first — does it take my child's age, is it a faith school, does it have a nursery — and the page could not answer any of it. Primary tables gain Ages, Religious character, Nursery and Constituency; secondary tables the same minus Nursery, which is a question about a different intake. An all-through school renders in both groups, so its nursery shows under primary alone. The measure moves to the second column rather than the last. Six columns overflow a phone and .tableWrap turns that into a horizontal swipe; with the measure last, the one number the page exists for is the one scrolled off the screen. Cell rules are the ones the school page already uses, so the two surfaces cannot disagree about the same school: "Does not apply", "None" and "Not applicable" all read as no religious character, and the en-dash age normalisation moves into formatAgeSpan, which formatAgeRange now delegates to. Backend: nursery_provision and parliamentary_constituency were not in the place response. Both are optional GIAS mart columns that data_loader degrades to NULL, and the `in rows.columns` guard keeps a mart the pipeline has not rebuilt working. Also fixes a live bug on the same line: SCHOOL_COLUMNS already ends with latitude and longitude, and the endpoint concatenated them again, so pandas dropped one of every duplicated pair and warned "columns are not unique" on each request. Ordered de-duplication removes the warning and the silent drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM |
||
|
|
ade9dbb3ba |
Merge pull request 'feat(analytics): measure the location layer, and stop calling it direct' (#132) from feat/place-analytics into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m44s
|
||
|
|
d1a8596208 |
feat(analytics): measure the location layer, and stop calling it direct
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 40s
The location pages were only half-tracked. Umami counts a pageview for each of the ~3,900 URLs automatically, but nothing else: components/ places contained no track() call, and place_viewed was not even a declared event name. The part that mattered was worse than a gap. getNavigationSource mapped a same-origin referrer to a funnel source and had no case for /schools/, so every school view arriving through the location layer fell through to 'direct' — the bucket you read as "typed the URL, no referrer". W2's whole purpose is funnelling search traffic onto school pages, so the one measurement that says whether it worked was reporting the wrong answer, and reporting it confidently. Verified live against staging: expected "place", received "direct". /schools/ is checked before /school/. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test in the wrong order silently merges them. place_viewed carries kind, slug, phase and school_count. kind is the reason it exists: whether to keep investing in these pages turns on which sort earns engagement, and a pageview cannot say, because all four families share the /schools/ prefix and only the registry knows which is which. It is a client component because PlaceView is a server component; one line in PlaceView covers all four families, since they all render through it. Both E2E journeys were verified failing against staging first — one because place_viewed does not exist there, the other on the exact "place" vs "direct" mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
a3c09d9b67 |
Merge pull request 'fix(suggest): the dropdown reopened on top of the search results' (#131) from fix/suggest-reopens-over-results into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m37s
Reviewed-on: #131 |
||
|
|
a7f4c86464 |
Merge pull request 'fix(search): the mobile hero search was indented by a card's padding' (#130) from fix/mobile-hero-search into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #130 |
||
|
|
0804566736 |
fix(test): drop a committed scratch probe, and close a hole in the guard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Code review, all three findings valid.
e2e/tests/__m.spec.ts was a throwaway probe used to measure the mobile
hero geometry. It asserts nothing, so it could never fail; it carried a
leftover `pick('form').constructor === Object ? null : null` that is
null either way and throws if no form matches; and it should never have
been committed. Deleted.
It survived because `rm -f e2e/tests/__m.spec.ts` ran with the shell
already inside e2e/, so the path resolved to e2e/e2e/tests/... — which
does not exist, and rm -f is silent about that. `git add -A` then swept
it in. I checked `git diff --stat` before committing, which lists only
tracked modifications and never shows an untracked file; `git status
--short` would have.
The scoping guard compared the last line of a rule's prelude against the
literal '.filterBar', so a regression written as a selector list —
`.filterBar, .other { padding }`, or the same split across two lines —
would have walked straight past the test meant to catch it. Selectors
are now split on commas and matched individually, and comments are
stripped first so a brace inside one cannot desynchronise the parse.
Verified against all three shapes: bare, inline comma list, and
multi-line comma list. Each is caught; each passes again once reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
|
||
|
|
55363cbd18 |
fix(suggest): the dropdown reopened on top of the search results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 55s
Three staging-gate failures, two of them one real bug. After a search, the results-page bar still holds the term in its input, so on every render the query was >= 2 characters and the suggestion list opened again — on top of the very results the search had just produced. Playwright reported it as "<li role=option ...> intercepts pointer events" while trying to click the first result; a reader would simply have found their first result unclickable. Both the school-detail and hero-map journeys failed on it, and neither is about autosuggest. Suggestions now answer typing, not the mere presence of a value: `hasTyped` gates the hook, is set on change, and is cleared when a search is submitted or a suggestion is chosen. A pre-filled input makes no request and shows no list. Third failure was my test, not the product. An unphased place page renders one table per phase, and an all-through school legitimately appears in both — so the page's school links were never one alphabetical run. The assertion collected them all together and only passed because no town it picked had held an all-through school. When the data gave Abbots Langley one, Breakspeare School appeared in the primary table and again in the secondary, and the test failed on correct behaviour. It now checks each table separately, and passes against the data that broke it. Guards: a jest test that a pre-filled input neither fetches nor opens (verified by reverting — it is the only one that fails), and an E2E journey that submits a search and then requires the first result to be clickable, which is the reader-facing version of the same thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
868eb344f5 |
Merge pull request 'fix(map): the hero map's fade to the header was hardcoded white' (#129) from fix/dark-map-fade into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #129 |
||
|
|
0b15497c09 |
fix(search): the mobile hero search was indented by a card's padding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m3s
Measured at 390px: the headline and lede sit at x=34, while the search
box, the hint and the location link all sat at x=48 and the field was
28px narrower than the copy above it.
The 14px came from `@media (max-width: 768px) { .filterBar { padding:
0.875rem } }`. That rule is for the results filter bar, which is a card
— background, border, shadow — and needs inner padding. The hero search
is not a card: .heroMode strips all of it, padding included.
Both selectors are specificity (0,1,0), so source order decides, and
.heroMode only wins because it is declared right after .filterBar. A
bare .filterBar rule inside a media query comes later and silently wins
instead. The two rules directly below this one in the same block were
already written as `.filterBar:not(.heroMode)`; this one was missed.
Scoping it aligns the search box, hint and location link to the same
left edge as the headline and gives the field back its 28px.
The location link also carried its own 6px of button padding, so its
label started further right than the hint even once the boxes agreed.
Pulled back with a negative margin, which keeps the tap target.
The guard is a stylesheet test: the failure is a plausible-looking
layout rather than a broken one, so nothing short of measuring or
looking would catch it. Verified by reverting: it names ".filterBar sets
padding".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
|
||
|
|
d55f6cce23 |
Merge pull request 'fix(suggest): let the dropdown out of the hero panel' (#128) from fix/hero-dropdown-clipping into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #128 |
||
|
|
3236efa846 |
fix(map): the hero map's fade to the header was hardcoded white
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 49s
The fade between the map band and the school header ramped through rgba(255,255,255,...) and landed on var(--bg-card). In the light theme that is white into white and invisible, as designed. In the dark theme it climbed to 95% WHITE and then met a near-black card, putting a bright band across the full width exactly where the map should dissolve into the title. Fading to the colour the gradient lands on is the whole trick, and it only works if that colour is a token — so --bg-card-rgb now exists in both theme blocks, matching the --hero-ground-rgb precedent. Two more defects in the same file, same cause, found while in there: The controls floating over the map paired a hardcoded white background with color: var(--text-primary), which resolves to #E9EEF0 in dark — near-white text on a near-white button. These deliberately do NOT follow the theme, because the map tiles are light in both, so the ink is now literal too and says why. A themed token is the wrong tool for a surface that never changes. The loading skeleton swept 50% white across var(--bg-secondary), which is a bright flash every 1.4s on a dark page. It now sweeps toward the card colour, a shade lighter than the ground in both themes. The guard is a stylesheet test rather than a render test, because the bug is invisible in the theme it was written for. Verified by reverting each fix in turn: it names .fade and .openHint exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
d5a6db289d |
fix(suggest): let the dropdown out of the hero panel
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
.heroPanel had overflow: hidden to clip its artwork and scrim to the rounded corners. It clipped the suggestion dropdown too. Measured on staging with the flag on: the list runs 482 to 802, the panel ends at 624 — so 178px of 320 was cut off, about half the options, with nothing on screen to say anything was missing. The two things that actually needed clipping now round themselves: .heroArt gets border-radius: inherit plus its own overflow, and the ::before scrim inherits the radius. Below 860px the artwork is a band flush with the top of the panel rather than a layer covering it, so it takes the top two corners only — inheriting all four would leave it floating with rounded corners against the copy. Nothing else depended on the panel clipping: .valueProps below it is entirely static, so a positioned dropdown paints above it without a z-index fight. The regression test asserts the LAST option is the element actually painted at its own coordinates. toBeVisible() would not have caught this — it checks for a non-empty box and visibility, and an ancestor's overflow clips neither. elementFromPoint catches clipping and occlusion alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
d8ccb5b733 |
Merge pull request 'feat(suggest): school autosuggest, and the rate-limit fix it needed first' (#127) from feat/school-autosuggest into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #127 |
||
|
|
0fa1a292c7 |
fix(api): bound what a forged CF-Connecting-IP can buy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Code review, both findings valid. The design doc claimed Cloudflare "replaces the header, so a browser cannot forge it", and that only the X-Forwarded-For fallback was forgeable. That is true only for traffic that actually passed through Cloudflare, and nothing in this process can verify that it did. Reaching the origin directly, both headers are equally attacker-controlled — and rotating CF-Connecting-IP mints a fresh rate-limit bucket per request, defeating per-client limits on every endpoint including the DataFrame-heavy /api/schools. Against abuse that is worse than the shared bucket it replaced, which at least capped everyone together. So the ceiling comes back. I dropped it earlier arguing it belonged at Cloudflare; that argument assumed the keying was sound, and it is not. GlobalRateLimitMiddleware counts all /api/ traffic in a fixed window against a total, independent of client identity, outermost so it refuses before any work happens. Written by hand because slowapi cannot express a global cap: default_limits and application_limits are both keyed by key_func, and the latter needs middleware this app does not install. It does not make the header trustworthy — it makes trusting it survivable. The real fix is Authenticated Origin Pulls or an origin firewall, now documented in DEPLOY.md as the open gap it is. 127.0.0.1 is exempt: the healthcheck curls localhost from inside the container, and starving it would restart the container and turn a load spike into an outage loop. Keyed on the peer address, never the Host header, which the caller sets. Second finding: suggest_schools_typesense promised "never raises" while the parsing loop sat outside the try, so int(None) on a malformed document would have made a keystroke a 500. The loop now skips bad rows rather than dropping the whole list — and a hit with no document no longer becomes a suggestion pointing at /school/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c3f044bd65 |
Merge pull request 'docs(flags): Unleash does not create flags by itself' (#126) from fix/flags-runbook into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m46s
Reviewed-on: #126 |
||
|
|
d2115364ae |
test(e2e): autosuggest journeys, gated on the flag
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m13s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m40s
Feature state is read from its observable effect — whether the search box is a combobox — because /api/flags is denied to the public on purpose. Same approach as the distance journeys. The three endpoint tests are ungated: /api/suggest is live whether or not the UI is, which is what lets it be smoke-tested in an environment where the feature is still dark. The flag-off journey asserts the plain search still works, not just that the combobox is absent. Verified against staging, where the flag is off: it passes and the flag-on journey correctly skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
28cf0a342c |
feat(suggest): wire autosuggest into the search box behind a flag
Off means off — no combobox role, no listener, no fetch. A test asserts the absence of the request, not just the absence of the dropdown, because a hidden-but-fetching control would still be spending the rate limit on a feature nobody can see. Enter with no active option falls through to the form's submit handler and searches the typed text exactly as before. The existing behaviour is preserved, not replaced, and that has its own test. Suppressed once the value parses as a postcode: the box takes a name OR a postcode, and suggesting schools during postcode entry fights the user. .omniBoxContainer gains position: relative — the dropdown is absolutely positioned and without it would have anchored to the page instead. Four render sites, all wired: page.tsx renders HomeView in the success path AND the catch fallback, and HomeView renders FilterBar as hero AND sticky. Missing any one would make the flag silently do nothing somewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
d88e77f459 |
feat(suggest): the dropdown, with combobox ARIA
Presentational only — it fetches nothing and owns no state, so the fetching rules and the ARIA rules can be read separately. onMouseDown, not onClick. The input's blur handler closes the list and blur fires before click, so a click handler never runs: the classic bug where a dropdown works perfectly by keyboard and is dead to the mouse. The plan's CSS guessed at token names like --color-surface. The real tokens are --bg-card, --border, --text-muted, --bg-secondary and --shadow-soft, and all five are redefined in the dark theme — invented names would have silently fallen back to hardcoded light values and broken dark mode. Local authority is rendered because there are many schools called 'St Mary's'; a list without it is unusable for exactly the query autosuggest exists to serve, which is what the test asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
06eb433db5 |
feat(suggest): debounced, abortable suggestion hook
The AbortController is correctness, not economy. Without it a slow response for 'st' can land after the fast one for 'st marys' and replace a correct list with a stale one — the classic autosuggest race. No cache: 'no-store', unlike the compare modal's search. This is the one endpoint where prefix queries repeat most across users, so discarding the browser cache and the backend's ETag 304s would be throwing away the cheapest win available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
1a6d349dad |
feat(suggest): GET /api/suggest, cacheable and DataFrame-free
A dedicated endpoint rather than a mode of /api/schools, because that path filters and sorts 25,000 pandas rows per query while holding the GIL — affordable once per search, not once per keystroke. A test asserts the distinction directly by making load_school_data raise and requiring the endpoint to answer anyway. Nothing errors on ordinary input: a short query, no matches, or Typesense being down are all 200 with an empty list. Cached deliberately. Prefix queries repeat enormously across users and school names change once a year, so s-maxage plus the existing ETag middleware turns most keystrokes into 304s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
75d3534d82 |
feat(suggest): Typesense rows for autosuggest, no DataFrame
search_schools_typesense returns URNs, which forces the caller to hydrate from the 25,000-row in-memory frame. Every field a suggestion needs is already in the Typesense document, so this returns documents and the caller needs no pandas at all — the difference between a query that can run per keystroke and one that cannot. Never raises. Typesense unreachable or erroring gives an empty list, because a dropdown that quietly stops appearing is the right failure for a keystroke path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
ff041544f2 |
fix(api): rate-limit per caller, not per proxy
The limiter keyed on request.client.host, which in staging and prod is the Next container — the backend has no published ports and nothing else can reach it. So every browser user on the site shared one 60/minute bucket per route. Measured against staging: 70 concurrent requests to /api/schools returned exactly 60 OK and 10 refused, from one machine. CF-Connecting-IP first. Cloudflare fronts both environments and overwrites any client-supplied value, which a parsed X-Forwarded-For chain does not guarantee. The XFF fallback is forgeable only from inside the Docker network. Named rather than hidden: the shared bucket was an accidental global throttle on a single-process backend, and correct per-user keying removes it. A real global ceiling belongs at Cloudflare, which is already in the path; slowapi cannot express one without a second Limiter and middleware this app does not install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
59265f78b6 |
docs(flags): Unleash does not create flags by itself
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 35s
The runbook said a flag 'appears in the Unleash UI after the backend has evaluated it once'. That is wrong. SDKs read definitions from the server and never register anything, and metrics for an unknown flag are discarded — so a declared flag is evaluated on every request, stays False forever, and never shows up until someone creates it by hand. Found the way these things usually are: staging had been running the flag code for a while and the UI was still empty. Also names the environment trap while here — each stack's token is scoped to one environment, so toggling the other does nothing visible and looks like the flag is broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
6e0a278340 |
docs(suggest): implementation plan, eight tasks
Self-review caught three defects in the plan. .omniBoxContainer, the wrapper the dropdown positions against, does not declare position: relative — without it the list anchors to the page. The postcode suppression test typed character by character, so it would have asserted no request while 'NW1' legitimately fires one; it now sets the value in one go. And the Enter-submits-search assertion needed waitFor, because updateURL pushes inside startTransition. Task 1 is the one to review hardest: it is the only unflagged change and it alters rate limiting for every endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
e651dd0d65 |
docs(suggest): the in-app global ceiling would not have worked
Reading slowapi rather than assuming: default_limits and application_limits are both evaluated with the same key_func, so they are per-client across routes, not global. And application_limits only apply 'if in_middleware' — this app installs no SlowAPIMiddleware, so they would never have fired at all. A genuine global cap would need a second Limiter with a constant key plus that middleware. Cloudflare is already in the path on both environments and does this at the right layer, so the ceiling is named as a follow-up there rather than built badly here. The risk that leaves is stated plainly in the risks section instead of being papered over with a mechanism that does not do the job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
22c113fc29 |
docs(suggest): design for school autosuggest
The load-bearing finding is not about autosuggest. The rate limiter keys on request.client.host, which in staging and prod is the Next container — so all browser users share one 60/min bucket per route. Measured against staging: 70 concurrent requests gave exactly 60 x 200 and 10 x 429. Eight concurrent searchers would 429 the site once each keystroke costs a request, so the keying fix is part of this work. Both environments are behind Cloudflare, which sets CF-Connecting-IP and overwrites any client-supplied value — trustworthy in a way a parsed X-Forwarded-For chain is not, and the backend is unreachable except through the Next proxy. Named honestly: the shared bucket has been an accidental global throttle on a single-process backend, so correct per-user keying removes a protection. A global ceiling ships with it rather than instead of it. Suggestions come from Typesense alone. The existing search path filters a 25,000-row DataFrame per query, which is exactly the cost a keystroke endpoint cannot pay, so there is deliberately no DataFrame fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
e953ee7c5f |
Merge pull request 'feat(flags): ship-dark feature flags, with last-distance-offered behind the first one' (#125) from feat/feature-flags into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 40s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m34s
Reviewed-on: #125 |
||
|
|
413d86cc3c |
chore(flags): wire UNLEASH_URL and the SDK cache volume into the stacks
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 27s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m59s
Both variables default to empty, so an environment without Unleash has every flag off — the correct dark state rather than a boot failure. The cache volume is the mitigation for the one real regression risk in this design: the SDK evaluates everything False until it syncs, so a backend cold-starting with an empty cache while Unleash is unreachable would make a *released* feature disappear. On a named volume the disk cache survives a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
4f01fbdedb |
test(e2e): make the distance journeys fail loudly, not skip quietly
The existing distance journeys all skip when no school has a published figure, which is right when the feature is off — and wrong when it is supposed to be on and is silently broken, because that shows up as a green run full of skips. The new gate fails in exactly that case. Feature state is read from the data, not from /api/flags: the public proxy denies that path on purpose, since it names unreleased features. Presence of the admission_distance key is the observable effect. Verified against staging, where the feature is currently on: the on-gate passes, the off-gate skips, the existing eight distance journeys are unaffected. One honest caveat — the /api/flags check passes on staging today because that image predates the endpoint, not because the denylist works. The denylist itself is covered by the jest unit test; this is defence in depth and becomes a real assertion once deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c3ba7aae0d |
feat(flags): ship last-distance-offered dark behind a flag
One gate, at the source. The frontend needs no change: DistanceSection
already returns null when distance_m is missing, and the admissions
block already conditions on (admissions || admissionDistance). Only 57
local authorities publish cut-offs, so the off-path is the commonest
path on the site and is well covered already.
Absent, not null. /api/schools/ is public and unauthenticated, so a
field left in the payload is a published field — the reasoning already
recorded in
|
||
|
|
54a30de0d8 |
feat(flags): server-side getFlags for the frontend
Ships without a consumer, deliberately. The first flag needs none — the backend withholds the field and the page follows — but 'UI elements on existing pages' is one of the three surfaces this capability exists for, and a flag layer that cannot gate one is incomplete. Never throws: an unreadable flag is a dark one, which matches the backend's fail-closed default. A page that 500s because the flags endpoint blinked would be a worse outcome than a hidden feature. Reading flags pins the calling route to a 300s ISR floor, since Next takes the lowest revalidate among a route's fetches. That matches what /school/[slug] already sits at, and it is the same property that makes a flip propagate without a webhook. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c30ad1db07 |
feat(flags): serve /api/flags, and keep the public proxy off it
The endpoint and its exposure control ship together on purpose. The moment /api/flags exists, app/api/[...path] forwards it — and the response names every unreleased feature the codebase knows about, along with whether it is on. Publishing that is the opposite of shipping dark. Denied on an exact first-segment match, not a prefix, so /api/flagship does not go down with /api/flags. Next reads the endpoint server-side over the Docker network, which never transits the public proxy. jest.setup.js now guards its browser globals. It runs for every suite, including the one that declares @jest-environment node to exercise the route handler — NextRequest needs Fetch API globals jsdom lacks, and there is no window there to define matchMedia on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
7424cef7c6 |
feat(flags): the registry and a fail-closed Unleash client
Unleash holds flag state; it does not hold the list of flags. REGISTRY is that list, because the SDK evaluates an unknown flag to False and without a registry that is an undeclared False — indistinguishable from a typo in a flag name. Fail-closed throughout, and never raises: an unset UNLEASH_URL, an unreachable server, a client that throws, an undeclared name — all False. A flag layer that can 500 a request path or stop the API booting is worse than one that is switched off. Every flag defaults to False, with no per-flag override, because a flag that defaults on is a kill switch and this is deliberately not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
01ccbb8e82 |
feat(flags): add the Unleash stack and its runbook
Its own Portainer stack, belonging to neither application stack: a staging redeploy must not be able to disturb production's flag state. One instance serves both. OSS Unleash ships development and production environments with environment-scoped client tokens, so the same flag holds independent state in each — which is what lets a feature be on in staging, where the E2E journeys exercise it, while production stays dark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c339c2f1a1 |
docs(flags): implementation plan, eight tasks
Task 1 is the Unleash stack and ends with a human step — the Portainer deploy and the token generation cannot be automated from here. Nothing else blocks on it: an unset UNLEASH_URL means every flag is False, which is what local development and CI get, so the whole suite runs without a flag server existing. Self-review caught three defects in the plan itself. get_supplementary_data takes (db, urn), not (urn), and the test DataFrame was minimised to the point where the endpoint would have failed for reasons unrelated to flags — both now copy the known-good shape from test_school_details.py. The proxy test needs the node jest environment, since NextRequest wants Fetch API globals jsdom does not provide. And the e2e off-state check hardcoded a URN, so a 404 page would have satisfied it without proving anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
e2ca3d79f9 |
docs(flags): drop the webhook — the seven-day premise was wrong
Next uses the LOWEST revalidate among a route's fetches, not the segment value. School pages fetch school details at 300s and place pages fetch national averages at 3600s, so the effective ISR period is five minutes and one hour respectively — not the seven days the segment declares. A flag flip therefore propagates on its own, well inside the monthly, by-hand cadence these flags are for. That deletes two webhook integrations, a revalidate route, a secret-in-query-string scheme, an idempotency requirement, and the rule that every fetch carry a cache tag — which was the part most likely to rot as fetches are added. Two constraints survive: a flag must never gate content on a force-static page, because app/admissions never revalidates; and a route-family flag must rebuild the sitemap, deferred with the route case since no flag in scope touches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c2364bf09e |
docs(flags): design for a ship-dark feature flag layer
Unleash self-hosted in its own Portainer stack, with FastAPI holding the
only SDK and Next reading flags through a tagged fetch.
The two hard parts are consequences of putting flag state in a service
rather than the repo: main stops being the whole truth about what is on,
and a flag can now change without the deploy that would have cleared the
caches. A code-declared registry bounds the first; webhook-driven
revalidateTag handles the second.
Cache tagging is deliberately coarse — every server fetch carries the
flags tag, not just the flags fetch itself. The first consumer proves
why: admission_distance changes the shape of /api/schools/{urn}, so a
narrow purge would leave ~25,000 school pages serving the pre-flip
render for a week, invisibly.
First consumer is the last-distance-offered feature, which is on main
and staging and has never reached production. It needs one gate, at the
API, because the frontend already no-ops on a missing field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
|
||
|
|
43e0621728 |
Merge pull request 'fix(places): phase links must stay in their own namespace' (#124) from fix/place-phase-links into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m31s
Reviewed-on: #124 |
||
|
|
d1358cc00f |
fix(places): phase links must stay in their own namespace
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the shape that belongs to towns alone. On an authority page that pointed into the town namespace. For 87 of the 151 authorities the target does not exist and the link 404s; for the other 64 it resolves to the town of the same name — a different set of schools, which is precisely the near-duplicate the two namespaces were introduced to prevent. On an outcode page it 404s outright. Two causes behind it, both a rule written twice and inherited by only one of the places that needed it. The authority phase route was in the spec and dropped by the plan, which built the three bare routes and no fourth. The sitemap is generated from the place registry, which was right about them all along, so 302 authority phase URLs have been submitted to Google and every one 404s. Adding the route makes the sitemap true and serves a real query — admissions are authority-run, so "primary schools in Kent" is how a parent searches before they have settled on a town. The outcode variants were the opposite: the registry computed phases for outcodes although the spec gives them no route, and the sitemap knew to skip them while the API did not. The registry now decides alone, and the sitemap's duplicate of that rule is gone. Also: an authority under the five-school threshold has no page, so the API sends a null slug for it and the page names it without linking. Two English authorities are in that position. It was unreachable in today's data — verified across the EC and TR outcodes — but the thin place redirect would have sent a reader to a 404 the year it isn't. The e2e journey now walks every /schools link a page of each family emits and requires a 200, which is the check that was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
865a69b54d |
Merge pull request 'feat(places): list schools alphabetically on place pages' (#123) from feat/place-alphabetical-sort into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m27s
Reviewed-on: #123 |
||
|
|
9cc87c41bb |
fix(places): a phase page needs results, not merely publishable schools
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m4s
Asked where schools with no results should sit in an alphabetical list, and found that some pages were almost entirely made of them. The per-phase threshold counted schools that were publishable — a result OR an Ofsted grade — while a phase page exists for its results column. /schools/kent/primary published with none of its five rows carrying a result; Minehead had one of seven, Buntingford one of five. Forty-four phase pages were majority-blank. It is the same rule as "no page without a local average", which was written into the spec as a thin-page control and never extended per phase. The threshold now counts schools with a result for that phase. It gates whether the page exists; it does not filter rows — a page that publishes still lists every school of the phase, because someone looking up a school by name has to find it whether or not it published results. 126 of 1,012 variant pages stop publishing: 62 primary, 64 secondary. Every one of them was a table with too little in it to be worth a page. The ordering itself is unchanged: pure A-Z, blanks interleaved. A school sits where its name says it does, and at roughly a tenth of rows that reads fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
8967966eef |
feat(places): list schools alphabetically on place pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 1m21s
Someone on a place page is usually looking for a school they can name, so the order should serve scanning for it rather than ranking. /api/rankings keeps its league-table ordering; this is a place-page decision, not a site-wide one. Sorted case-insensitively, or a capitalised name would sort ahead of every lowercase one. The change made five pieces of copy untrue, so they go with it. The phase variant titled itself "— Ranked", and all four route families described themselves as "ranked by SATs and GCSE results". A page that opens by claiming an order it does not keep is worse than one that claims nothing. The ItemList markup carried `position` with no declared order, which reads as a ranking. It now declares ItemListOrderAscending, so the structured data says what the table does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |