68a192e430e41200804fa5a104f76a8f7280264f
30
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
68a192e430 |
Merge remote-tracking branch 'origin/main' into feat/ks4-destinations
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 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m4s
# Conflicts: # nextjs-app/__tests__/components/darkThemeSafety.test.ts |
||
|
|
68b6417149 |
feat(destinations): types and secondary section flags
Destinations are secondary-only, so the flags go on computeSecondaryFlags rather than computeSchoolFlags. A phase counts as present only when some pupil group carries categories — an empty block would otherwise open a nav entry pointing at a section that never renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob |
||
|
|
1980d79eee |
feat(destinations): the disclosure rules, as executable guards
The destination categories sum to the cohort and DfE publishes the cohort total, so a lone suppressed cell is recoverable by subtraction. canAggregate, canRenderPublishedAggregate and canRenderBar are what stop a consumer doing that; toBarSegments throws rather than leaving a readable gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
5ddc7314fd |
fix(seo): canonicalise on the www host, which is the one that serves 200
The apex 301s to www at Cloudflare, but metadataBase, the school-page canonical, robots.txt's Sitemap: line and the sitemap's own <loc> entries all named the apex. Every one of those pointed Google at a redirect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
ea5249a2ea |
fix(admissions): state distance in miles throughout, never mixed
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 58s
The postcode check read "69 m away — inside the September 2026 cut-off of 0.17 miles". Both numbers are right and the sentence is still useless: the reader has to convert one of them to check a comparison we had already made for them. The cause was a readability rule of mine in formatCutoffDistance, which swapped to metres below 100m on the grounds that "0.04 miles" carries less than "69 m". Taken one figure at a time that holds. Taken in a sentence containing two figures it guarantees a mismatch whenever they fall either side of the threshold — and a 270m cut-off with a nearby home does exactly that. Miles now lead everywhere. It is the unit UK school admissions runs on: councils publish cut-offs in miles (90% of the collected source rows), and it is what a parent has already been quoted in their booklet and offer letter. The metric figure survives only as support beside the miles figure on the Admissions tile, where it converts the same value rather than presenting a second one to compare. Below 0.01 miles the decimal places run out rather than the unit being wrong, so a very short distance is described — "under 0.01 miles" — instead of rounding to a flat "0.00 miles", which would read as no distance at all. Both figures in the verdict now go through one formatter with no fallback that could reach for another unit. The old `?? "N m"` fallbacks on that line were a second route to the same defect and are gone. Covered by a sweep over sixty home/cut-off combinations spanning the old switch point, asserting no verdict contains a metric reading and that exactly two miles figures appear; plus the reported case pinned verbatim, and an e2e guard on the rendered verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
c9a1892bfb |
feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.
The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.
Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.
What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.
Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.
One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.
The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
a72323874f |
feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
88c653215d |
feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.
There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.
Data
* tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
(urn, year, school_name), because school_name carries the admission
route: (urn, year) alone collides on 118 keys and a reload would have
silently dropped every band but one.
* stg_school_distance applies a 25 m – 25 km plausibility band. The source
contains 0.0-mile rows (published where a school filled on a higher
criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
all of which would put a visibly wrong number on a live page.
* fact_admission_distance collapses routes to one row per school per year
using the furthest, and keeps route_count so the page can say the figure
is the widest of several bands rather than the one for a given child.
Serving
* Kept out of fact_admissions: that mart is EES-derived and near-complete
for England, this one covers 57 LAs, and the two refresh independently.
* Latest year only. Coverage is ragged — a school may have 2021 and 2026
and nothing between — so a history array would invite a trend line drawn
through gaps that are absences of publication, not of a cut-off.
* The Admissions section now renders on either source. 3% of the schools
that render have a cut-off and no EES admissions row, and gating on
admissions alone would have hidden the figure on those pages.
Interface
* The year travels with the figure everywhere it appears; a cut-off
detached from its admissions round is not a fact about anything.
* "Not a fixed catchment — it moves every year" sits under every instance,
because that is the inference a parent will otherwise draw.
* Replaces a hardcoded "Historical distance cut-off data is not available
for this school" that appeared on every secondary page, including the
ones whose council does publish it. The absence is now stated only when
it is real, and names the authority that would hold it.
The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.
The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
5044c24895 |
refactor(detail): extract derived flags and nav items to lib
Pure data-shape logic moves out of the client components so page.tsx can compute the section list on the server without importing them. The secondary page is not a variant of the primary one -- different section ids (gcse, wellbeing), different flags, and History gated on more than one year rather than at least one -- so it gets its own computeSecondaryFlags and buildSecondaryNavItems rather than bending a shared function. Adds 16 unit tests covering the all-through and special-school branches, previously reachable only through a full component render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc85254ad2 |
fix(compare): a no-results school no longer blanks the trend chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
Adding a school with no performance data to a comparison made every school's trend line disappear until that school was removed. Root cause: /api/compare returns such a school with a single phantom yearly_data row (the dim_school LEFT JOIN) whose year is null. In buildCompareChart, Math.trunc(null) is 0, so the axis was seeded at year 0; fillAcademicYears then walked 0, 101, 202, … and hit its 50-step cap long before reaching the real years, leaving every school's series mapped entirely to null. Fix: ignore yearly rows without a real numeric year when building the axis and the per-school year map. Regression test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ae6ef6860b |
fix: don't portray special schools as failing the mainstream benchmark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m1s
Special schools, PRUs and alternative provision teach pupils with SEND who sit the same KS2/KS4 assessments but very few reach the mainstream "expected standard". Their headline attainment is therefore ~0% (or a very low Attainment 8), and the site was comparing that to the England average and painting it red — e.g. Greenmead School (a community special school) rendered as "0.0% — −62 pts below England average" with three 0% red SATs bars. That portrays a special school as catastrophically failing against a benchmark that doesn't fit it. Add a shared `isSpecialSchool()` helper (detects every DfE special-school establishment type — all contain "special" — plus PRUs / alternative provision) and drop the mainstream England comparison + "below" framing for these schools across every surface: - Detail (primary + secondary): a plain-English context note explaining the school is special and why the comparison isn't shown; England-average delta chips, "England avg" hints, the SATs national markers, the Attainment-8 "vs national" bar and the trend chart's England overlay are all suppressed. An all-zero placeholder SATs row hides the (empty) subject bar chart and the "why is combined lower" bridge. - Rankings / search rows (primary + secondary): the mainstream RWM / Attainment 8 stat shows "—" with no "vs national" delta, instead of "0% · −62 vs national". - Compare: special schools' attainment values are dropped (no misleading dot at 0% / no "Below England average" chip); progress banding, which IS a fair measure for special schools, is kept. Belt-and-braces zero-guard: a whole-row zero attainment (special or a suppressed cohort) is also treated as not-comparable, while a legitimate single 0 (e.g. 0% exceeding at a mainstream school) stays comparable. Tests: new isSpecialSchool unit tests (every DfE special type matched, no mainstream false positives); an e2e journey asserts Greenmead shows the special-school note and no England-average comparison. tsc clean; 108/108 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
fdda52ff0a |
fix(detail): filter Ofsted sentinel codes, show sixth-form judgement, label carried-forward grades
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m23s
Batch A of applying compare-screen learnings to the detail page (points 2-3):
- Point 2: the legacy OEIF sub-judgement grid rendered any non-null value
via OFSTED_LABELS[value], so Ofsted's sentinel code 9 ('not applicable')
leaked as an undefined label, and the sixth-form judgement was dropped
entirely. New shared ofstedLegacyAreas() helper returns only real grades
(1-4) and includes Sixth Form provision. Both detail views + the
all-same-grade check use it.
- Point 3: the OEIF disclaimer always claimed 'Ofsted no longer makes an
overall judgement' even when showing a carried-forward grade. It's now
grade_source-aware and labels carried-forward grades as such, matching
the compare screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
|
||
|
|
5ec4f3f7cd |
fix(list/map): badge report-card schools as Report Card, not their carried-forward grade
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m48s
The search-result cards and map pins keyed report-card detection off
ofsted_framework === 'ReportCard', but the API sets ofsted_framework to the
raw event grouping ('Schools - S5'); worse, ofsted_grade (the carried-forward
legacy grade) was checked first and won. So report-card schools were badged
by their old grade — Barclay's pin/card read 'Outstanding · 2021' instead of
'Report Card · 2026'. Same root cause as the detail-page fix, different
surface.
Expose ofsted_rc_date on the list serialization (the report-card inspection
date, non-null only for report cards) and make both badge builders treat a
present rc-date as winning over any grade, using its year. Removes the dead
framework === 'ReportCard' branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
|
||
|
|
f3fa12806b |
fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
|
||
|
|
5944d88f0b |
fix(compare): expert sign-off must-fixes — phase-matched admissions, Ofsted sentinel codes, selective-school copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
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 2m53s
M1: admissions rounds are now selected by the active phase tab (admissionsForPhase) — an all-through school's Year 7 round no longer masquerades as Reception odds beside pure primaries; honest per-cell and section fallbacks name the round (Reception / Year 7). M2: Ofsted sentinel codes (9 = not applicable) never render as judgement chips, and the sixth-form judgement — previously dropped — now renders for schools that have one. M3: 'What this means' is phase- and type-aware: selective schools get entrance-test framing, secondary faith schools a faith-criteria note, and the primaries' distance template never appears on the secondary tab (admissions_policy now exposed in compare school_info). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
b4b0249a06 |
Fix Ofsted transitional inspections, phase tab exclusions, and FSM benchmark comparison
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m9s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
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) Failing after 10s
|
||
|
|
66bc5523f6 |
fix(compare): mobile measure-first cards to match the mockup
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m57s
The grid sections (At a glance, Ofsted, Getting a place, Who goes there) collapsed generically on mobile — grey label pills, full names wrapping to 3 lines, no dots — making the page ~2x the mockup's height and 'significantly different' from the mobile design. Each measure is now wrapped in a <Measure> that is display:contents on desktop (so the label + cells still flow into the shared aligned grid, unchanged) and a white card on mobile with compact [dot][short name] [value] rows — matching the mobile mockup. The sticky school bar becomes scrollable short-name pills on mobile too. Adds a shortName() util. Desktop layout is unchanged (display:contents dissolves the wrapper). Validated the card mechanism and real content shapes (report-card cell, badges, %+chip rows) via static previews at both widths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
519584f34b |
feat(compare): trends explorer with England line; gap-honest axis; series regression guard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
2573cd2490 |
feat(compare): academics strips with England anchors and More measures
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
48ca042b08 |
feat(compare): comprehension logic (report cards, admissions, verdicts, strips)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
45ab479062 |
feat(ui): mark proposed-to-close schools in listings and detail pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m46s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m1s
Amber tag in listing rows (option A) and a slim notice strip under the detail-page header (option E): proposed for closure, formal process not necessarily started, check with the local authority before applying. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22769b6295 |
feat(compare): readable comparison chart on mobile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s
The compare chart squashed clustered schools into a thin band (y pinned 0-100) under an in-chart title + per-school legend that ate ~40% of a 300px card, leaving converging lines indistinguishable on phones. - Auto-fit the y-axis to the data on all viewports (computeYBounds in lib/utils: padded + min-span for percentages, symmetric around 0 for progress, fitted for scores; negative pct-named trend metrics are not zero-clamped). - Distinct point style per school (circle/triangle/rect/rectRot/star) as secondary encoding for convergence and colour-blindness. - Mobile: drop in-chart title/legend/axis titles; add a chip row (colour dot + name) that doubles as tap-to-focus — highlights one school's line and dims the rest. Chart card 300px -> 340px, nearly all plot. - Fix a latent colour mismatch: datasets were built from Object.entries whose integer-like URN keys enumerate in ascending numeric order, desyncing line colours from card colours; the chart now receives the ordered school list. - Union years across schools instead of taking the first school's. - Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile. Unit tests for metricKind/computeYBounds; e2e journey covers the mobile chips and focus toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4a52735356 |
feat(sdlc): staging environment + automated staging→prod pipeline
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 10m0s
PR Checks / Backend Smoke (pull_request) Successful in 48s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s
- pr-checks.yml: PR gate — frontend typecheck+jest, backend import smoke, image builds (no push), Claude AI review posted as PR comment (severe findings block merge) - deploy.yml (replaces build-and-push.yml): merge to main builds+pushes images tagged sha-<sha>/staging, deploys the staging Portainer stack via webhook, runs Playwright E2E journeys against staging, then retags the verified images :prod (previous kept as :prod-previous) and deploys prod - docker-compose.portainer.staging.yml: second Portainer stack — :staging images, sc_staging_* names, own macvlan IPs, Airflow on 8081; data bootstrapped from source via the staging Airflow DAGs - prod compose now pins :prod instead of :latest (only the promotion step moves it; :latest is no longer published) - e2e/: 6 Playwright journeys (search, postcode, detail, compare, rankings) driven by BASE_URL — the promotion gate - scripts/ci/ai_review.py: Claude review with structured JSON findings - docs/DEPLOY.md: full SDLC doc incl. one-time setup checklist and rollback - replaced removed 'next lint' with tsc typecheck; fixed stale jest tests (slug URLs, N/A formatting, stable trend, fake-timer setup) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL |
||
|
|
ce2bfea91b |
fix(search): badge inspected-but-ungraded schools as "Inspected", not "Not yet inspected"
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
A school inspected under the OEIF framework after September 2024 has an inspection on record (ofsted_date set) but no overall_effectiveness grade, since Ofsted no longer issues an overall judgement. buildOfstedListBadge had no branch for this and fell through to "Not yet inspected", while the detail page's hero chip correctly reported it as inspected — so the same school read two contradictory ways. Add an "Inspected · YYYY" branch that fires when an inspection is on record (date or framework present) but no grade and not a Report Card, mirroring the hero chip's fallback. Genuinely un-inspected schools (all Ofsted fields null) still show "Not yet inspected". Add an .ofstedInspected badge style (neutral slate) distinct from the grey pending state, and cover both cases with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8a6758b591 |
feat(utils): add buildOfstedListBadge helper and fetchNationalAverages
- Add ofsted_framework field to School type - Add OfstedListBadge interface and buildOfstedListBadge pure function to utils.ts - Add fetchNationalAverages API function that calls GET /api/national-averages - Add test suite for buildOfstedListBadge (all 6 new tests pass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
ff7f5487e6 |
Complete Next.js migration with SSR and Docker deployment
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router - Add server-side rendering for all pages (Home, Compare, Rankings) - Create individual school pages with dynamic routing (/school/[urn]) - Implement Chart.js and Leaflet map integrations - Add comprehensive SEO with sitemap, robots.txt, and JSON-LD - Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js) - Update CI/CD pipeline to build both backend and frontend images - Fix Dockerfile to include devDependencies for TypeScript compilation - Add Jest testing configuration - Implement performance optimizations (code splitting, caching) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> |