Religious character now sits with the other labelled header details (near
County / Constituency) as "Religious character: <denomination>" ("None" for
Does not apply / None), instead of as an identity chip.
On mobile/tablet (≤768px) the header details block was hidden outright; it's
now collapsed behind a "Show all details" toggle that reveals the full block
(headteacher, website, pupils, trust, phone, religious character, county,
constituency). Applied to both the primary and secondary detail views for
parity. Extends the e2e journey with the mobile toggle behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously the chip was hidden for "Does not apply"/"None". Show it in all
cases, rendering "None" for those two sentinel values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add seven school-identity fields to the detail header (both primary and
secondary views): age range, religious character, nursery and sixth-form
indicators as chips; telephone (tel: link), county and parliamentary
constituency as header details. religious_denomination, age_range and
has_sixth_form were already served; telephone, nursery_provision, county
and parliamentary_constituency are newly wired through the marts query
(with a NULL fallback for un-rebuilt marts, mirroring has_sixth_form) and
the school_info API response.
Remove three UI sections the backend never populated (always null): Year 1
Phonics, the SEN "types of additional needs" breakdown, and the average
class-size card — along with their now-dead props, route plumbing, and the
SenDetail/Phonics types + class_size_avg field.
Extend the e2e detail journey to assert the Phonics section is gone and the
new header fields render when the record carries them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grade boxes rendered inconsistently: Safeguarding "Met" was a small
0.8125rem inline pill while grade values ("Strong") were 1.25rem full-width
chips, and because category labels wrap to one or two lines each value sat at
a different vertical position. Two-line values like "Expected standard" also
produced a taller box.
Add a scoped `.gradeGrid` modifier (Report Card + OEIF grids in both the
primary and secondary detail views) that reserves two label lines so every
chip shares a baseline, and gives all value chips one font size, padding and
min-height — long text wraps inside an equal-height chip via max-width:100%.
Scoped rather than touching the shared `.metricCard`/`.metricValue` used by
the finance, phonics and admissions sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Three review points on the special-schools change:
1. Copy accuracy — the context note said "Its pupils have special educational
needs" for every isSpecialSchool() match, but the helper also matches pupil
referral units and alternative provision, whose pupils are educated outside
a mainstream setting (e.g. after exclusion) and are not necessarily SEND.
Extracted a shared <SpecialSchoolNote> with type-aware copy: SEND wording
only for genuine special schools; PRUs/AP get their own accurate wording.
2. Same-school trend was conflated with the England comparison — SchoolRow's
year-over-year trend arrow (and the school's own figure) were gated on the
same flag that drops the vs-England delta, hiding a still-meaningful trend
for special schools with real data. Split the two: the school's OWN RWM
figure + trend show whenever there's a real value (special schools
included; only a placeholder all-zero row is hidden); only the vs-England
delta is additionally dropped for special/PRU/AP. Mirrored in
SecondarySchoolRow (own Attainment 8 shown; only the vs-LA delta dropped).
3. De-duplicated the .specialNote CSS (was copy-pasted between the two detail
view module files) into SpecialSchoolNote.module.css, owned by the shared
component so it can't drift.
New SpecialSchoolNote unit tests assert SEND wording for special schools and
NOT for PRUs/AP. tsc clean; 112/112 unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Review feedback: the rankings/search rows treated any exactly-0 attainment as
not comparable regardless of school type, so a genuine 0 at a mainstream
school rendered as "—" (looks like missing data) and dropped its delta —
stricter and inconsistent with SchoolDetailView's placeholder check.
- SchoolRow: match SchoolDetailView's ks2Placeholder signature — suppress only
when ALL of RWM + reading + writing + maths are 0 (special/suppressed
signature), not on a bare rwm === 0. A genuine 0% combined (some pupils met
individual subjects but not all three) is not all-zero, so it stays
comparable and shows its real figure + trend.
- SecondarySchoolRow: Attainment 8 is a single 0–80 score with no subject
breakdown to form an all-zero signature, so key off establishment type only
(drop the bare att8 === 0 guard). A genuine (if extreme) 0.0 shows its value
+ LA delta.
- For consistency, apply the same to the detail views: drop the bare
attainment_8_score === 0 guard (KS4 keys off isSpecial only); KS2 keeps the
all-four-subjects-zero placeholder signature.
Special schools / PRUs / AP are still handled via isSpecialSchool everywhere.
tsc clean; 108/108 unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
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
Batch C (points 7-10):
- Point 7: the admissions section shows the first-choice offer rate but no
sense of how severe a low rate is. Reuse the compare screen's banded
summariseAdmissions ('More than half of first choices missed out' etc.)
as an interpretive line under the tiles. (Selective/faith framing was
already present via admissionsTag.)
- Point 8: replace the jargon P8-suspended banner ('not used for
accountability purposes ... treat with caution') with the compare
screen's plain-English 'no KS2 baseline (COVID)' explanation.
- Points 9 (disadvantaged cohort sizes) and 10 (FSM benchmark) don't apply
to the detail page: it shows disadvantaged composition, not disadvantaged
attainment, and no FSM-vs-benchmark comparison — so there's no small-cohort
caveat or cross-definition fallback to fix here.
- Also fixed a stray lowercase 'national avg' provenance string Batch B
missed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Batch B (points 5-6): the detail page called every national figure a
'National avg'. All of them come from the official-DfE national-averages
marts (KS2/KS4 headlines), so they are England averages — relabel to
match the compare screen's provenance convention ('England average' for
official figures; the detail page has no computed benchmarks, so no
'state-school average (computed)' label is needed). Point 6 (anchoring
every number) is already satisfied on the detail page via DeltaChip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
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
The detail views detected report cards via ofsted.framework === 'ReportCard',
but the API sets framework to the raw event grouping ('Schools - S5') even
for report-card schools — so the check never matched: report-card schools
rendered as legacy 'Ofsted Rating' badges dated to a pre-Nov-2025
inspection (e.g. Barclay shown as 'Outstanding, Inspected 7 October 2021'
instead of its Feb 2026 report card).
Detect report cards by the presence of the report_card object (matching
the compare screen), and date them with rc_inspection_date, never the
legacy inspection_date. Applies to both primary and secondary detail views.
New e2e assertion against a real report-card school gates it.
Note: the list/map surface has the same latent issue via ofsted_framework
(LeafletMapInner) — flagged as a separate follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
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>
Removes the 'What Parents Say' section and all supporting elements:
Frontend:
- Drop the OfstedParentView type, the parent_view field, the survey
section and the 'X% would recommend' callouts in the primary and
secondary detail views, the Parents nav item, and the parent-view CSS.
Backend:
- Remove the FactParentView model, its loading in data_loader, and
parent_view from the school-details API response.
- Bump SCHEMA_VERSION to 6 and add an idempotent drop step
(DROP TABLE IF EXISTS marts.fact_parent_view) to the CLI migration;
add scripts/sql/drop_fact_parent_view.sql to apply directly to the
dbt-owned marts DBs on staging and prod.
Pipeline:
- Delete the stg_parent_view + fact_parent_view dbt models and their
source/schema entries, the tap-uk-parent-view Meltano extractor, and
the monthly Parent View DAG; drop it from the Dockerfile and the
staging bootstrap docs.
The rest of dbt (which builds every mart the app reads) is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On ≤640px the floating Compare button becomes a 40px round glyph (+ / ✓,
aria-labelled) like the section-nav compare icon, instead of a full-width
text pill over the map. The OSM attribution moves to the map band's
top-left in preview so it no longer collides with the school name sliding
up under the fade; fullscreen keeps Leaflet's default bottom-right since
the zoom control occupies the top-left there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bare "3-11" range gave no unit. Add a display-only formatAgeRange
helper ("3-11" -> "Ages 3–11", en-dash) used in the primary/secondary
search rows and the secondary detail badge. The raw age_range field is
left untouched so sixth-form detection (.includes('18')) still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The report link hardcoded provider type 21, which 404s for every school
whose Ofsted provider type isn't 21 (nurseries -> 20, academies -> 23, etc.).
Use Ofsted's URN-based find-inspection-report URL, which redirects to the
correct provider page for any school, and rename the link to "Ofsted reports"
since it points to the school's Ofsted page (all reports), not one report.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the header's at-a-glance stats row with a location map that sits
atop the hero and blends into the school title. The map is a static,
non-interactive preview (never traps page scroll) with a coral pin; the
whole band — or the inline "View on map ↗" link by the address — opens a
fullscreen, interactive map. Compare floats glassy over the band.
The separate "Location" section (and its nav item) is removed; the map now
lives only in the hero. Schools without lat/long render the header with no
map band, as before.
New: SchoolHeroMap (fullscreen wrapper, forwardRef open handle) +
LeafletHeroMapInner (minimal single-school map with interaction toggle).
Applied to both primary and secondary detail views; dead heroStats/tone/
mapContainer CSS removed (shared .heroStat* card classes kept).
Also drops the orphaned "Latest data" note and does not reintroduce an
Ofsted strip in the hero (it would duplicate the Ofsted section directly
below). Mockup kept at mockups/header-map-hero.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replicate the Autotrader pattern: pull the page-back control out of the
sticky section-nav bar and place a standalone "← Back" text link above the
header card, on the page background, where it scrolls away with the page.
It keeps the context-aware behaviour (router.back → /search fallback); the
secondary view gains the same fallback for parity (it previously used a bare
router.back() that dead-ends on deep links).
The pinned slot the Back button vacated now holds a "↑ Top" control that
smooth-scrolls to the top, so the sticky bar keeps a useful affordance.
Applies to both primary and secondary detail views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hero header rendered the Ofsted signal twice — once as a chip in the
strip and again as a tile in the at-a-glance scorecard — and an
Oversubscribed chip already covered by the First-choice tile's footnote.
Remove the chip strip on both primary and secondary detail views, leaving
the scorecard trio (Results · Ofsted · First-choice) as the single home
for the headline numbers. Widen the scorecard's render gate to hasHeroStats
so a school with an Ofsted rating but no results still shows its Ofsted
signal (previously carried by the chip strip). Drop the now-dead .heroChip
CSS, keeping the shared .tone-* tokens the scorecard's serif number uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorders the detail-page sections so the most-sought content leads, based on
30-day section_nav_used analytics (nav clicks over-count buried sections, so a
low, high-demand section is a strong "surface me" signal):
Primary: Ofsted → SATs Results → Admissions → Pupils & Inclusion → History →
Phonics → What Parents Say → School Life → Location → Local Area → Finances
Secondary: Ofsted → GCSEs → Admissions → History → Parents → Wellbeing →
Location → Finances
Key moves: Admissions (23%, most-clicked) lifted from #6→#3; Pupils & Inclusion
(18%) from #7→#4; History (13%) off the bottom. Ofsted stays first (its low click
rate is positional — everyone reaches it first). Low-demand context sections
(Local Area, Finances) stay last. Brings the primary page in line with secondary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
to HomeView via slot props so their JSX stays out of the client bundle.
Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
+ stale-while-revalidate, ETag generation, and 304 on If-None-Match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.
14 events wired:
Discovery (3)
search_submitted FilterBar submit + near_me path
near_me_used all geolocation outcomes
empty_results search returns 0 schools
Engagement (5)
school_viewed SchoolDetail + Secondary on mount, with
urn / phase / local_authority / from
section_nav_used section-nav links on both detail views
chart_metric_changed mobile chart chip switch
metric_compared_in_rankings rankings metric dropdown
external_link_clicked Ofsted / school website / DfE (declarative
data-umami-event attributes)
Conversion (5)
compare_school_added search/rankings/detail/compare sources
compare_school_removed detail toggle and compare page
compare_viewed once per session when there's a selection
(school_count, phase_mix)
compare_metric_changed compare page metric dropdown
compare_shared native sheet vs clipboard distinguished
Operational (1)
api_error caught in handleResponse, includes
endpoint / status / route
Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Upgrades the existing "Pupils" stat to include a compact split bar and
percentage hint for mixed schools (single-sex schools already carry a
"Boys's/Girls's school" badge, so the split would be redundant).
Wires fact_pupil_characteristics into the API: new SQLAlchemy model and
a real census block in /api/schools/{urn} replacing the prior null stub.
On the primary detail page the inline "Pupils: 241" text is replaced by
a richer block (display number + bar + "52% girls · 48% boys"). On the
secondary detail page the existing "Total pupils" hero stat card grows
the bar and hint beneath the number. Both fall back to the previous
text-only rendering when census gender data is missing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- GCSE top metrics (Att8, P8, Eng+Maths 4+/5+) promoted to heroStatCard
teal-tinted cards with Playfair serif values and DeltaChip vs national
- Attainment 8 visual bar: 0–80 scale with coral national-average marker
and pill label, mirrors the SatsChart concept for a score metric
- Progress 8 number line: −3 to +3 axis showing CI band, zero baseline,
and a teal/coral dot for the school's score (hidden when P8 suspended)
- SEN section upgraded from plain metricCard to heroStatCard grid
- History table moved into a details/summary accordion (collapsed by
default); PerformanceChart now lives at the top of the History section
always visible above the disclosure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The staging model aliased EES's total_number_places_offered column as
published_admission_number, but PAN is the school's published capacity
(not exposed by EES at school level) — what we actually have is the
count of places offered in a given admissions round. The misnomer
propagated to the mart, SQLAlchemy model, API response, TS types, and
UI copy ("places per year", "(PAN)").
Rename end-to-end and fix the UI labels:
- "29 places for 42 first-choice applications"
→ "29 places offered for 42 first-choice applications"
- "Reception/Year 7 places per year"
→ "Reception/Year 7 places offered"
- drop the misleading "(PAN)" suffix in the secondary view
Also add a comment in stg_ees_admissions clarifying this is the number
of places offered, not PAN. Requires dbt to rebuild fact_admissions
(marts are materialized as tables) before the backend can start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
latestResults.total_pupils in KS4 data is the Year 11 exam cohort (~1/7
of the school), not the full school roll. Prefer schoolInfo.total_pupils
(sourced from GIAS NumberOfPupils) which is the statutory census headcount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the dashed reference line was a flat horizontal at the latest
year's national average across all historical data, implying the national
figure was constant. Now the backend returns per-year averages in `by_year`
and the chart maps each data year to its own national average, so the
reference line correctly reflects how the national picture changed over time
(including COVID recovery dip/recovery).
- backend: /api/national-averages now includes `by_year` list alongside
existing `year`/`primary`/`secondary` latest-year snapshot
- types: NationalAverages extended with `by_year: NationalAveragesYear[]`
- PerformanceChart: accepts `nationalByYear` prop; builds per-year series
aligned to school data years, falling back to scalar prop if absent
- SchoolDetailView + SecondarySchoolDetailView: pass `nationalAvg.by_year`
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bump school name to clamp(2rem,5vw,3.25rem) Playfair Display
- Add hero signal chips strip (framework-aware Ofsted + coral Oversubscribed)
- Add at-a-glance stats row: Att8 with delta vs national, Ofsted serif tile, first-choice rate
- Active section highlighting in sticky nav via IntersectionObserver
- Collapse OEIF Ofsted section to prose when all sub-grades match overall
- Pass nationalAtt8Avg reference line to PerformanceChart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace tab-based show/hide with always-visible sections and anchor
link navigation, matching the primary school detail page behaviour.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The overview tab was sparse for schools without parent view data, showing
only 2 cards. Now shows:
- Individual Ofsted grades when no overall effectiveness (post-Sept 2024)
- Admissions summary card (PAN, applications, 1st choice rate)
- School context card (pupils, capacity, SEN support, EHCP)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>