1bb3e0360fba0f15935a91001783a482835542cb
904
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1bb3e0360f |
feat(search): open postcode searches on the map on phones too
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
Trial the map as the default for every postcode search, not only on desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows how often readers switch away). The view is now derived: the reader's choice once made, else the default for the kind of search. That follows a hero search through client-side navigation without syncing state. With nothing left varying by device, the user-agent default goes (lib/device.ts, the headers() read in page.tsx). That settles two review points: the HTML no longer needs Vary: User-Agent, and there is no narrow-window fallback left to flash the map before the list. Review fix in LeafletMapInner: the card reopened only when the selection or the school list changed, so a rebuild caused by the centre, reference point or radius closed it for good while the page still had the school selected. The selection effect now follows a pins-rebuilt counter. SchoolMap memoises its fallback centre, which was a fresh array on every render and would have rebuilt every pin each time. Phones hide the map view's list pane, so it no longer builds up to 500 hidden cards there (decided after mount, so hydration still matches). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4e4b30e812 |
feat(search): align the map view with the mockup and open it on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The map view now follows option B of the results-controls mockups: - The list sits in a pane on the left, under the result count and sort, and the map fills the rest of the screen below the pinned toolbar. The toolbar's height is measured, so the split ends 1rem above the bottom of the screen however the controls wrap. - Pins are brand-teal dots and the selected one is coral. The search location is an ink dot, the search radius a dashed circle with its distance, and the view fits that circle. Tiles are muted and the zoom sits under the fullscreen button. - A school picked on the map or in the list opens a card on the map (View, + Compare, following the basket), and its list card is ringed and scrolled into view. Phones keep the bottom sheet. - The list cards show the full name, Ofsted and school type, the headline figure and pupils. The map cards now follow the list rows: no England benchmark for special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead showed "0% RWM -62 pts vs national"). That rule moves to a shared listRwmValue helper. A postcode search opens on the map for desktop browsers, chosen on the server from the user agent so the list never paints first. The client falls back to the list below 1024px, and follows the default through client-side navigation from the hero until the reader picks a view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0e177ca2ec |
Merge pull request 'feat(search): pin the results toolbar and float the map button on phones' (#157) from feat/results-toolbar into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 43s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m11s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #157 |
||
|
|
367a07c15d |
fix(search): address review on the results toolbar
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
- Refold the phone search, and resync its text, when the query changes without a submit (back/forward, "Use my location"). The summary and the unfolded input could otherwise describe two different searches. - Cap the "More filters" panel at every width, not only on phones, so an open panel scrolls inside the pinned toolbar on a short screen. - Drop the phone filter row's right-edge fade once it is scrolled to the end or does not overflow (the MOBILE.md pattern), and give focus rings room inside the scroll clip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
983a581555 |
feat(search): pin the results toolbar and float the map button on phones
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The List/Map switch was a small grey control beside the results heading, and the filters were plain dropdowns labelled "All Phases" and "Advanced". Both scrolled away with the first result. Search, filters and the List/Map switch now share one card pinned under the header. Distance, phase and school type are pill controls in the row; "Advanced" becomes "More filters" and counts only what it hides. The switch is filled brand teal and says which view is on (aria-pressed). On phones the search folds to a one-line summary once made, the filter pills scroll sideways, and a floating Map/List button sits above the tab bar in place of the toolbar switch. The selected pin's card now stacks under that button instead of covering the tab bar. Switching view from far down the list scrolls back to the top of the results, and each switch is tracked as results_view_changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b34feb8e98 |
Merge pull request 'feat(compare): five per phase, not five overall' (#156) from feat/compare-limit-per-phase into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m57s
Reviewed-on: #156 |
||
|
|
cc99865bd4 |
style(api): drop the em dash from the sitemap url docstring
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
587cfe3f0b |
fix(compare): give every basket entry a real phase
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
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) Canceled after 1m6s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Review of the per-phase limit found entries reaching the basket with no phase, and a phase-less entry holds a slot in both groups: - Nearby-school cards added without one. The API now returns each peer's own phase (its pool is a phase group, so an all-through school can sit beside a primary); the button passes it through, and an older API simply leaves the conservative both-groups count in place. - Baskets saved before this change were never migrated. The compare page now backfills missing phases from the data it already fetches, and never overwrites a phase the basket has. - "16 plus" counted against both groups; it is secondary, as the API's PHASE_GROUPS files it. Also rewraps the HomeView doc comment the previous commit left awkward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0a4c051ee5 |
feat(compare): limit the basket to five per phase, not five overall
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
A parent choosing a primary and a secondary school at once hit the old cap of five total. The basket now holds up to five primary and five secondary schools (ten in all), matching the compare page's phase tabs. Schools that could land in either tab (all-through, special schools with phase "Not applicable", unknown phase) count against both groups, so no tab ever exceeds the five-slot chart palette and point styles. - lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits - search modal disables only the full group and says which one - rankings rows carry the phase of the tab they are ranked under - shared ?urns= links are trimmed per group - copy: compare metadata, homepage value prop, How it works card now name primary and secondary schools (also better for search intent) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
343b40c645 |
Merge pull request 'copy(web): replace em dashes in public copy' (#155) from copy/remove-em-dashes into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
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 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m51s
Reviewed-on: #155 |
||
|
|
d1688ac150 |
copy(web): replace em dashes in public copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
271ffe92d4 |
Merge pull request 'fix(web): lift the mobile jump sheet above the bottom tab bar' (#154) from fix/jump-sheet-under-bottom-bar into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m23s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m0s
Reviewed-on: #154 |
||
|
|
0571d1c0ff |
fix(web): stop the sheet-open rule stealing .sectionNav's layout
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
Review catch, and a bad one: the previous commit anchored its insertion on `padding: 0.5rem 0.75rem;` and closed .sectionNav there. Everything that followed in the rule — margin-bottom, box-shadow, display: flex, align-items, gap — was orphaned into .sectionNavSheetOpen, which is only applied while the mobile jump sheet is open. So the sticky nav lost its flex layout, spacing and shadow in the closed state, which is virtually every page view on every school detail page. A site-wide regression introduced by a fix for one mobile menu. Redone by anchoring on the complete rule, closing brace included, so nothing can be orphaned. .sectionNav is now byte-identical to main and the diff is purely additive; .sectionNavSheetOpen carries the z-index and nothing else. The staging experiment that validated this fix set nav.style.zIndex = '1100' with every other declaration intact, so it was always testing this version rather than the broken one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
180d6e9b3e |
fix(web): lift the jump sheet above the bottom tab bar
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 37s
On mobile the last item in "Jump to section" was painted over by the fixed bottom tab bar and could not be tapped. Reported as nearby schools missing from the menu; it was there, underneath the bar. The sticky nav sets `position: sticky` with `z-index: 10`, which makes it a stacking context. The sheet's own `z-index: 1600` therefore orders it only inside that context — against the tab bar (z-index 1000) the nav's 10 is what counts, so the bar wins. Verified on staging: every menu item returns itself from elementFromPoint except the last, which returns the tab bar. Latent rather than new. With five sections the list stopped just above the bar; "Nearby schools" made six, and the sixth is the first to reach it. Any section added later would have done the same. Lifted only while the sheet is open, and only to 1100 — above the bar, below the comparison toast (2000), the fullscreen map (5000) and the info popover (9999). The backdrop rises with it, so tapping over the bar now dismisses the sheet instead of navigating away. The journey asks what a thumb asks: for each item, whether it is the topmost element at its own centre. A bounding-box check cannot see this — the item is in the viewport and the right size, just underneath something. Confirmed to fail against current staging, naming "Nearby schools", before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
80f405123e |
Merge pull request 'test(e2e): wait for the carousel's smooth scroll to settle before measuring it' (#153) from fix/nearby-journey-waits-for-smooth-scroll into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
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 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m53s
Reviewed-on: #153 |
||
|
|
077aca6008 |
test(e2e): wait for the carousel's smooth scroll to settle before measuring it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 32s
Second failure of the same assertion, and the first fix addressed a real but different problem. This is the one that was actually producing the number. The arrows scroll with `behavior: 'smooth'`. The test polled for "has it moved at all" — satisfied 50ms in, at 13px of a 1300px journey — then recorded the offset, clicked, and recorded again. The row was still travelling throughout, so the delta it measured was the tail of the arrow's animation, not the effect of the selection. Hence a deterministic 659, roughly half of the 1317 this school's row scrolls. Measured against staging rather than reasoned about: the animation runs about 700ms, and the samples are in the helper's comment. settledScrollLeft waits for two identical readings before trusting one. The app was never at fault — driving staging by hand, the offset holds at 1317 across the selection, exactly as intended. Verified against staging both ways: main's version of this test fails there, this version passes, along with all three mobile widths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9dba5ff1ff |
Merge pull request 'test(e2e): fix the nearby-schools journey clicking a card it scrolled past' (#152) from fix/nearby-scroll-journey-clicks-offscreen-card into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m23s
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 25s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m49s
Reviewed-on: #152 |
||
|
|
f530a912bc |
test(e2e): stop the nearby-schools journey clicking a card it scrolled past
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The assertion that adding to compare does not reset the carousel failed on staging: 537 → 2. The app was not at fault. Playwright scrolls a target into view before clicking, and the test clicked the FIRST card's button after paging the row to the end — so Playwright scrolled the container back to the start, and the assertion measured that. Reproduced on a static page with no React on it: a snap scroller at 615, Playwright clicks the off-screen first card, scrollLeft becomes 2. Scroll-snap was ruled out first — mandatory, proximity and no-snap all behave identically when the button mutates in place. Now clicks the last card's button, which is visible at the end of the travel, and allows a few pixels for snap and sub-pixel adjustment while still failing on a reset to the start. The property was never actually under test before; it is now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
029fe8d8a6 |
Merge pull request 'fix: order nearby schools by distance, not by how alike they are' (#151) from fix/nearby-schools-order-by-distance into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
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 30s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m51s
Reviewed-on: #151 |
||
|
|
cd1c5d1e1a |
docs: revise the spec to the design that survived staging
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 57s
The spec described the tier system as the design of record. It is gone, so the document was describing something the code deliberately does not do. The revision note and the "why not, having built it the other way first" passage are kept rather than overwritten. The mistake is the instructive part: treating a preference as a constraint inverted the ranking, and the stopping rule added to prevent weak distant matches is what guaranteed six Catholic schools and no community school down the road. A spec that quietly presents the second design as the plan teaches nobody why the first one failed. The mockup link is annotated as one revision behind rather than silently left to look current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
5c0ccc693d |
fix: order nearby schools by distance, not by how alike they are
Reported from staging: a Catholic primary showed six Catholic primaries, none of them close enough to be a real option, and omitted the community school down the road. Three causes, compounding. Ranking put tier before distance, so a faith match at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule — added so a cap of six would not drag in weak distant matches — filled the row from the best tier before it ever widened, which is what made every card Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city for a primary, whose catchments are routinely under a mile. The premise was backwards. For a parent, distance is a constraint and intake is a preference; a school beyond a primary catchment is not a weaker option, it is not an option. So distance now decides the order and nothing else does. The hard filters are untouched — they were always where the defensibility lived. Similarity survives as chips on the card: reported, so a reader applies their own weighting, rather than ranked, so we apply ours for them. Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity bound, not a target: ordering already handles density, so the cap only decides what happens where an area is sparse. A primary with nothing inside two miles now renders no section, which is the honest answer. Deleted: the tier system, the stopping rule, the tier-dependent lede, the `tier` field, the tier-3 fallback chip and its style. select_similar also stops taking is_secondary — it reads the phase from the subject's own row, so no caller can hand it one that disagrees with the data. The heading is now "Other schools nearby". The hard filters still guarantee a comparable set, but nothing ranks on likeness, so the heading no longer says it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
151cf4bc80 |
Merge pull request 'feat: similar schools nearby on the detail page' (#150) from feat/similar-schools-nearby into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 6s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 42s
Reviewed-on: #150 |
||
|
|
83dc5ae5dc |
docs: correct the metric rule for "16 plus"
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m11s
The spec said the metric follows the template the page renders. That is no longer true for GIAS phase 6: a sixth-form college renders the primary template but is matched, correctly, against secondaries. The rule is phase group membership, decided once in is_secondary_phase — and the section's lede noun comes from the school's phase rather than its template for the same reason. 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> |
||
|
|
bd2a6c385b |
test(e2e): cover the similar-schools section, compare hand-off and mobile widths
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 37s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m1s
Two things here cannot be covered anywhere else. jsdom has no layout, so scrollWidth and clientWidth are both 0 and the arrows' disabled state can only be measured by a real engine. And the scroll position surviving a selection is DOM state rather than React state, so only a real browser can prove the row does not jump back when the footer re-renders. MOBILE.md asks for a Playwright width check and records that it was not written because Playwright was not in the project. It is — this suite — so the check exists now, scoped to the page this feature touches. 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> |
||
|
|
91314a80b5 |
feat(web): similar schools section, honest about what it matched
Server-rendered cards inside a client carousel that scrolls rather than paginates, so all six links stay in the initial HTML and the row still works with JavaScript off. Three client islands, split by what each needs: a school, the whole selection, a DOM ref. The lede claims a similar intake only when no card came from tier 3, chips list what a school actually shares, a missing figure reads "Not published", and the neighbour's number carries no valence colour — green and terracotta mean "against England" everywhere else, and colouring it here would read as ranking the neighbours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
52b00ac752 |
feat(api): serve similar schools on the detail endpoint
Rides in the existing payload rather than a new endpoint: the page already makes one server fetch for its data, and /school/[slug] regenerates weekly, so the per-request cost is paid once per school per week. Wrapped so a failure in selection never 500s a page that is otherwise complete — the posture get_supplementary_data already takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b571d9c549 |
feat(api): decide which nearby schools a page may offer
Hard filters encode claims the section may not make — a selective school is not an alternative to a non-selective one, a special school is not comparable to a mainstream one, a Girls school is not an option for a Boys school's reader — so they never relax. Soft preferences describe closeness of fit, so they relax across three tiers, and only far enough to reach three; the remaining slots up to six fill from the tiers already opened. PHASE_GROUPS moves to schemas.py so this module can share it without importing app, which would be a cycle. _mask() exists because Series.apply on an empty Series returns a DataFrame, and using that as a mask drops every column — so the next lookup raises KeyError instead of yielding no rows. A special school with no special school near it empties the frame at the provision filter, which is the ordinary case for most special schools, so this was a crash on a common path rather than an edge case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a23e3657d |
docs: meet the mobile baseline, which the carousel was not doing
Checked the section against MOBILE.md at its three reference widths instead of assuming the breakpoints were enough. Two real failures at 360px. The arrows sat in the heading's flex row, taking 96px from a 328px card and crushing the lede into a four-line column — for a control that swiping already provides. Below 640px they are now gone, the header is a single column, one card shows at 86% so the next one peeks, and the affordance is the right-edge scroll-fade MOBILE.md already documents for horizontal scrollers. The fade lifts at the end of the travel, so the at-end state is computed whether or not an arrow exists to consume it. The arrow and add-to-compare buttons were 40px against a 44px floor. Both are 44 now. A card title's own box is shorter, but its hit area is the whole card through the ::after overlay, so it passes on the target that actually receives the tap. 360, 390 and 430 now all report zero overflow, no failing tap targets and no text under 11px. MOBILE.md wanted a Playwright width check and recorded that Playwright was not in the project; it is, so the journey now carries one for this page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4e8f02599 |
docs: six schools behind a carousel, and a rule for when to stop widening
Three schools is not a neighbourhood in inner London, so the cap is six with three visible and arrows for the rest. Raising the cap exposes something the old cap hid. Tiers exist to reach a usable set, and with six slots a naive loop would keep widening to fill them — dragging in tier-3 schools ten miles away to sit beside three good matches that had already earned the row. So tiers now stop relaxing once three are found, and the remaining slots are filled only from the tiers already used. Four tier-1 matches never open tier 2. The carousel scrolls a list rather than swapping a view: all six cards are in the initial HTML, so every link stays crawlable and the row still scrolls with JavaScript off. The arrows' edge test carries an 8px tolerance because the scroller's focus-ring padding is the first snap position — a row at rest reports scrollLeft 2, and an exact test for 0 left the back arrow live and pointing nowhere. Caught in the mockup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b62dc17532 |
docs: drop the method disclosure, keep the one caveat that earns its place
The "how these schools are chosen" panel restated what the section already shows — the phase in the lede, the shared characteristics on each card, the distance above each name — so it cost space to say nothing new. One line survives, and it is not a method note. A reader who sees "0.6 miles away" and takes it for the walk has been misled by us, and no other element on the card corrects that. The rest were claims the selection rules keep true without narrating them. Also records what happens past the third school: surplus matches are dropped silently, because NearbyPlaces below already leads to the full lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d7762d796 |
docs: implementation plan for similar schools nearby
Five tasks, each ending in a green test run and a commit: the pure selection module, the endpoint key, the section and its two client islands, the wiring into both templates, and the journey. The selection logic gets its own module rather than another 200 lines in app.py, which means the tier rules are testable against a synthetic frame with no TestClient, no database and no monkeypatch. Moving PHASE_GROUPS to schemas.py is what keeps that import acyclic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3650f7d8b7 |
docs: design for similar schools nearby on the detail page
A school page links outward to its places and never to another school. This section adds that edge: three nearby schools of the same phase and a comparable intake, each a crawlable link and each addable to the basket. The design separates hard filters from soft preferences and never confuses them. Selectivity, provision and opposite-sex intake are claims the section cannot make, so they never relax, even where that means no section renders. Gender and religious character describe closeness of fit, so they relax in tiers — and the card states what actually survived rather than padding with a match it did not earn. Includes the mockup the design is drawn against, with all three tier states live in both themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfce308f1f |
Merge pull request 'fix(ci): make a failed release check say what it actually saw' (#149) from fix/release-check-diagnostics into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
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 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m9s
Reviewed-on: #149 |
||
|
|
64ae71d7ab |
fix(ci): make a failed release check say what it actually saw
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m4s
The staging poller swallowed every failure identically, so a run that timed out told us only that the expected release never appeared — not whether the proxy refused us, the endpoint was down, or the containers were still serving an older build. The public staging proxy also answers 403 to urllib's default user agent while the release endpoint is healthy, which looked exactly like a deployment that never arrived. Identify the poller, and report each distinct observation once: HTTP status, connection failure type, invalid JSON, or the release identities actually reported. The timeout error carries the last observation and the identity it wanted. Responses and the base URL stay out of the logs — only validated sha/build_id fields are echoed back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ab084dd3a |
Merge pull request 'feat: verify what we actually deployed, and publish data all-or-nothing' (#148) from feat/release-identity-and-reliability-gate into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m31s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m27s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Failing after 5m3s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Skipped
Reviewed-on: #148 |
||
|
|
5ad1cbfb53 |
fix(api): bound the search candidate set instead of draining Typesense
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 39s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 6m44s
Fetching every match kept scoped searches correct but left the number of round trips in the caller's hands: a one-letter query, or a deliberately broad one, walked the whole collection a page at a time. Cap the candidate set at 1,000 URNs — four pages — and return the relevance-ordered prefix when the ceiling is hit. That is still far more than one page, so the API's own authority, phase and postcode filters keep the matches they need, while latency and upstream load stay bounded. A capped query is logged so a genuinely truncated search is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c901cd0d1 |
feat(ci): gate promotion on the image set that actually passed E2E
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 6m3s
Staging health polling asked only whether something answered HTTP 200 at the base URL. It could not tell the new deployment from the old one, so journeys could pass against the previous release, and concurrent merges could move the staging tags underneath a run in flight. Each staging run now mints a build ID and stamps all three images with the commit and that ID, as labels and — for frontend and backend — as a build-time JSON file that environment overrides cannot rewrite. /release.json reports both identities uncached, and scripts/ci/release.py polls for the expected pair before and after the journeys. Only then are the captured build digests tagged verified-<sha>. Promotion resolves those verified tags to immutable digests, revalidates their labels, and refuses a mixed or incomplete set before any :prod tag moves. The whole staging workflow shares one concurrency group with cancellation disabled, so releases serialise. The scripts are stdlib-only and unit-tested against mocked registry and HTTP behaviour; PR checks now run the pipeline and CI suites too. The runbook records what this cannot prove locally, and that the first rollout needs a commit built by this workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b41218e6e |
fix(web): show an outage as an outage, and drop superseded fetches
The home page caught every fetch failure and rendered its empty state, so a backend outage looked like a site with no schools in it. School pages turned any error into notFound(), which told visitors — and crawlers — that a real school had ceased to exist. Place fetches did the same by returning [] and null. Failures now reach a retryable error boundary; only a genuine 404 still calls notFound(). "Load more" and the map fetch resolved against whatever state existed when they returned, so results from an abandoned search appended themselves to the new ones. Each fetch now carries an AbortController and checks that its search scope is still current before touching state. The map only records its cache key on success, so a failed load retries instead of pinning the stale marker set. Jest ignored .next/, whose build output otherwise shadowed real suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9b75f54206 |
fix(api): publish a validated dataset, and stop truncating search
Reload cleared the caches first and rebuilt afterwards, so any failure left the API serving nothing, and requests arriving mid-reload saw a half-swapped state. It now builds and validates the replacement frames, place registry, reverse index and sitemaps off the request loop, then publishes them in one synchronous step under a lock. A failed reload returns 503 and keeps the previous data. Sitemap regeneration takes the same path rather than clearing the live registry up front. Typesense search returned at most one page of hits and used an empty list for both "no matches" and "search is down", so a genuine empty result silently fell back to substring matching. It now pages through every candidate and returns None only on failure; the fallback matches literally, since a query containing regex metacharacters used to throw. Empty datasets answer 503 rather than 200-with-nothing or a misleading 404, so callers can tell an outage from an absent school. Adds /api/release, which reports the build identity baked into the image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
38bc17cab3 |
feat(search): validate the index before the alias points at it
The old sync created a collection, imported batches without reading a single import response, and swapped the alias regardless. A partial import published a half-empty index, and two overlapping DAG runs could prune each other's collections. Publication now checks every import response and the final document count before upserting the alias, and holds a session-scoped advisory lock across the read and the publish so concurrent runs serialise. Cleanup keeps the previous collection as a rollback pointer and is best-effort: an uncertain alias response must never delete what might still be live. Also parses the Typesense URL properly instead of splitting on colons, which mangled any host carrying a scheme and a default port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc156058fe |
Merge pull request 'docs: describe the system that exists, and remove the importer's remains' (#147) from docs/current-truth-and-legacy-inventory into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 22s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m22s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 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 1m57s
Reviewed-on: #147 |
||
|
|
1d8858fbda |
chore: remove the code the legacy CSV importer left behind
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m2s
`backend/migration.py` and `scripts/migrate_csv_to_db.py` import `School`, `SchoolResult`, `init_db` and `set_db_schema_version` — names that no longer exist. `scripts/geocode_schools.py` imports the same removed ORM model. None of the three can be imported against the current backend, so they were not dormant utilities anyone could fall back on; they were files that would fail on the first line. `backend/version.py` existed only to hand `SCHEMA_VERSION` to that importer, and the FastAPI lifespan performs no version-triggered import. Three symbols go with them, each confirmed to have no caller: the unvectorised `haversine_distance`, superseded by the inline NumPy calculation in search; `fetcher`, an SWR helper for a dependency this project does not install; and `kmToMiles`. `calculateDistance` stays — CutoffMapPanel uses it. Two comments pointed at `migrate_csv_to_db.py --drop` to explain why Payload owns its own schema. The reason survives the script: blog content must stay clear of the school marts and Airflow's metadata. Reworded rather than deleted, so the constraint keeps its justification. docs/LEGACY_CODE.md records what was removed and where to find it in history. It also records what was deliberately *not* removed, which is the more useful half: unused UI components awaiting a design decision, manual data utilities whose operators a repository search cannot see, and fallbacks that look obsolete but are load-bearing — `data_loader.py`'s older-mart branches, the generated GIAS dictionary copies, and the `legacy`-named dbt models that annual DAG selectors explicitly include. A zero-import count is evidence, not a verdict. The scripts that fetch DfE CSVs are marked historical and kept, pending confirmation that nobody runs them by hand. Checked: 190 backend tests, 429 frontend tests, `tsc --noEmit` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan |
||
|
|
eaf5e5d180 |
docs: describe the system that exists, not the one we started with
The README still opened on "Primary School Compass", a KS2 tool for Wandsworth and Merton served by FastAPI and vanilla JavaScript with Chart.js. Every layer of that sentence is now wrong: coverage is England-wide across KS2, KS4, all-through and post-16, Next.js owns the public UI, and school data comes from dbt-built `marts.*` rather than CSVs loaded at startup. The setup instructions walked a reader into a virtualenv and a CSV import that cannot build the current schema, so following the docs produced an empty database and a wrong mental model at the same time. Replace the narrative docs with two reference documents that were checked against the code: docs/ARCHITECTURE.md for request flow, data ownership, the backend/frontend module boundaries and the real publication sequence, and docs/DEVELOPMENT.md for the checks that actually run, including the container and CI version skew that makes "just run pytest" misleading. The env examples drifted the same way. ALLOWED_ORIGINS is a JSON array, not a comma-separated list; the frontend needs FASTAPI_URL, DATABASE_URL and PAYLOAD_SECRET, none of which were documented; and RATE_LIMIT_BURST, DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE were presented as tuning controls the routes do not consult. Each is now stated as it behaves. MIGRATION_SUMMARY.md keeps its content but gains a banner, because it reads like setup instructions and is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan |
||
|
|
be780ebe13 |
Merge pull request 'fix(seo): declare the share card, which the route group stopped inheriting' (#146) from fix/og-image-route-group into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m21s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m58s
Reviewed-on: #146 |
||
|
|
b6c2cd5116 |
fix(seo): declare the share card, which the route group stopped inheriting
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
The staging E2E gate's one failure. Every link to the site pasted into a chat has been rendering bare. Staging serves og:title, og:description, og:url, og:site_name, og:type and twitter:card, and no og:image at all. So metadata from the layout reaches the page; only the file convention does not. app/opengraph-image.tsx does work — _not-found, which lives in the app root segment, carries an og:image from it in the build output. It does not reach the site's pages, which live in the (frontend) route group whose own layout.tsx is the root layout. The icon conventions are not affected: /icon.png and /apple-icon.png are both linked correctly on the same page, verified against staging. The asymmetry is the whole bug, and it arrived with the route-group split that Payload required. The file stays at the app root. Moving metadata files into a route group is what drops /robots.txt and hashes /icon.png, which CLAUDE.md records and which this must not undo — the build still emits all four of /robots.txt, /icon.png, /apple-icon.png and /opengraph-image. The root layout points at the route instead, and metadataBase makes it absolute, which the journey needs since it calls new URL() on the value. twitter.images is set for the same reason: the card is declared summary_large_image, and claiming a large-image card while supplying no image is worse than claiming a summary card. Checked before fixing that og:image was the only broken assertion in that journey: the test aborts at line 911, so its apple-touch-icon and maskable-icon assertions had never run. All four of those assets return 200 image/png from staging, so this does not simply move the failure further down the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq |
||
|
|
dc79d653e5 |
Merge pull request 'feat(seo): link school pages into the location layer' (#145) from feat/school-page-place-links into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m29s
Reviewed-on: #145 |
||
|
|
b0d5334e06 |
perf(places): index the reverse lookup, and isolate the registry in tests
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m47s
Two review findings, both confirmed before fixing.
The client fixture in test_school_details.py patched load_school_data
but not _place_registry, which is a module-level cache. A probe settled
it rather than an argument: poisoning the global with a registry built
from data the fixture never saw, then issuing the fixture's own
request, returned that other dataset's places. So the new
`places == []` assertion was satisfied by a stale registry exactly as
well as by the fixture's own data, and proved nothing. Every other test
that touches place data already reset it; the fixture predates places
existing and was never updated. It resets it now.
places_for_urn walked every place in the registry and did a tuple
membership test against each, on /api/schools/{urn}, the site's
highest-traffic endpoint. It now reads a dict built once per registry.
Measured against a synthetic corpus of 27k schools in 1,650 places:
0.118ms per request becomes 0.0001ms, with the index built once in
21ms. Production carries ~5,000 places, so the scan there is larger
again. The absolute saving per request is small; the point is that it
is repeated on every school page view and costs nothing to remove.
The index is cached against the registry by identity rather than
behind a second flag. Anything that drops _place_registry — every test
that touches place data does — gets a fresh registry object, which no
longer matches what the index was built from, so the index rebuilds
with it. A separate _place_index = None would be one more thing to
forget, and a stale reverse index is precisely the first finding's bug
wearing a different hat.
That invalidation has its own test, and the test was checked by
breaking the identity check: five tests fail without it, so three
existing ones were already relying on it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
|