Planning surfaced a gap: the two detail views' CSS modules share 79 class
names, 22 with differing rules, 16 of those used in section markup by both
views. Shared sections cannot use one stylesheet without visual change.
Resolution: union the 14 incidental-drift differences (defensive overflow
properties never back-ported between the views), and keep the 2 genuine
visual differences (.genderBar, .heroStatValue) as distinct classes behind a
variant prop. Adds an isolated CSS-merge commit so a visual regression
bisects to the merge rather than a JSX move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design for splitting SchoolDetailView (65KB) and SecondarySchoolDetailView
(46KB) into server section components behind a small client shell, following
the existing components/compare/ decomposition pattern.
Key constraint: a server component imported by a client component becomes
client, so sections are composed in page.tsx and passed through the shell as
children. Moving /api/national-averages server-side is a prerequisite, since
the England-comparison deltas feed most sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The graceful-degradation fallback keys off the column named in a Postgres
UndefinedColumn error, but the matcher only stripped an `s.` alias. The two
new dim_location columns (county, parliamentary_constituency) are selected
via the `l.` alias and Postgres reports them unquoted as
"column l.county does not exist" — which the old regex failed to match at
all, returning None.
If dim_school is rebuilt (telephone/nursery present) but dim_location is not
yet (county/parliamentary_constituency missing) — plausible since they are
independently-rebuilt dbt models — the fallback branch never matched and
load_school_data_as_dataframe() returned an empty DataFrame, showing zero
schools sitewide instead of degrading those columns to NULL.
Generalise the alias prefix to `\w+\.` and cover the l.-qualified case in
tests.
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>
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>
Replace X-Frame-Options: SAMEORIGIN with a CSP frame-ancestors directive
so analytics.schoolcompare.co.uk (Umami heatmap/recorder) can embed the
site while all other origins stay blocked. X-Frame-Options cannot allow a
specific sibling subdomain (ALLOW-FROM is deprecated/ignored by modern
browsers), so frame-ancestors is the correct replacement.
Also update the nginx snippet in DEPLOYMENT.md to match, so the reverse
proxy doesn't re-inject a conflicting X-Frame-Options header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trend chart was capped at 280px tall on desktop, squishing the
lines together and making them hard to read. Raise the base
.chartContainer height to 380px in both the primary and secondary
detail views.
Mobile is unaffected: the base value is already overridden to
height:auto by the existing max-width:768px query, where
PerformanceChart's own .chartWrapper carries the canvas height.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Adds the Umami session recorder (recorder.js) that powers heatmaps,
alongside the existing analytics tracker. Loaded via next/script with
the same data-domains guard as the main tracker so it only fires on the
production hostnames — staging runs the same image under a different
host and must not feed the heatmap.
Cookieless: recorder.js uses Umami's server-side session model (no
cookie or local storage on the device), so it adds no new PECR
consent trigger.
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
The KS2 SATs chart drew a single national-average line spanning the
full height of each subject's chart area, positioned at the national
*expected* value. But the area stacks two bars — Expected and Exceeding
— and the higher-standard/greater-depth national is a very different,
much lower figure (e.g. reading higher standard ~29% vs expected ~75%).
So the line crossed the Exceeding bar at the wrong place, making every
school's exceeding result look far below national when it wasn't.
The per-subject higher-standard nationals were already computed in the
fact_ks2_national_averages mart; they just weren't serialized. Fix:
- backend: add reading_high_pct, writing_gd_pct (writing = greater
depth) and maths_high_pct to the national-averages payload.
- SchoolDetailView: pass a nationalExceedingPct per subject, mapping
writing to the greater-depth figure.
- SatsChart: replace the single full-height line with a national marker
on each bar's own track (coral tick + "nat X%" in the bar header), so
Expected and Exceeding each sit against the correct benchmark.
KS2 only; the secondary Attainment 8 chart already uses one line for
one measure and is untouched.
Verified: tsc --noEmit, next build, and backend pytest (national
averages marts, incl. a new test guarding the per-subject nationals).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
The selective-school registration and faith-school SIF warnings were
added as prose in the criteria steps, where they read as ordinary
sentences and were easy to skim past. Pulled them into structured
callout boxes with an amber "note" treatment (using the page's own
gold accent tokens), a warning glyph, and a bold lead-in — so the two
deadlines parents most often miss actually stand out.
- Added a `callouts` field to Step; render as a callout group between
the body and the tool link (both primary and secondary timelines).
- Reverted the warning text back out of the step bodies so it isn't
duplicated.
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Follow-up audit of the rankings and admissions pages.
Admissions (AdmissionsView.tsx):
- National Offer Day no longer claims offers publish "from 12:01 am";
release times are set per-council (often late afternoon, some
overnight), so it now tells parents to check their council's page.
- Secondary preference count no longer states a flat "up to six"
(that's London/Pan-London); most LAs allow three to six. Mirrors the
hedge the primary step already used.
- Added the two deadlines that most often catch parents out: selective
schools' separate entrance-test registration (months earlier), and
faith schools' supplementary information form sent direct to the
school. Covered in both the primary and secondary criteria steps.
- Reworded the equal-preference tip so it's precise: order is the
tie-break among schools you qualify for (you get the highest-ranked
one), not irrelevant.
Rankings (RankingsView.tsx):
- Subtitle "Top-performing schools by X" -> "Schools ranked by X", so
it isn't nonsensical for context/equity/absence metrics.
- KS2 progress isn't published for 2023/24 or 2024/25 (no KS1
baseline). Selecting a primary progress metric on a recent year used
to dead-end on a generic "No rankings found". Added a scoped caveat
to the progress hint and an explanatory empty-state (primary only —
secondary Progress 8 is published).
Added an admissions smoke journey (static content, stable milestones).
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Two internal-consistency fixes on the "What you'll see" mock:
- The Attainment 8 bar had drifted from the numbers beside it (fill 68%
/ line 57% vs a 54.8 score and 45.9 national avg). Restored the mock's
original 1:1 value-to-percent mapping: fill 54.8%, national line 45.9%.
- The report card grouped "Safeguarding: Met" in the same five-point
list as the graded areas, but safeguarding is a separate binary
met/not-met judgement under the real framework. Moved it below the
list behind a divider, with an outlined chip and a "separate check"
label so it no longer reads as a graded area.
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Follow-up to the accuracy pass — the medium-severity wording issues
from the same expert review:
- KS2 "Exceeding" isn't a DfE term. The above-expected level is the
"higher standard" for the reading and maths tests and "greater depth"
for teacher-assessed writing. Labelled per subject, and brought the
illustrative reading value down from an implausible 73% (national
higher standard is ~29%) to a realistic strong-school figure.
- Attributed inspection outcomes to Ofsted, not only the DfE (Ofsted is
a separate non-ministerial department).
- Hero: "2026/2027 admissions results" -> "the 2026/27 admissions
round" (admissions produce offers, not results, and we don't hold
per-school offer outcomes).
Tightened .miniRowHead so the longer "Greater depth" label stays on one
line and the three subject columns keep their bars aligned (the visual
is desktop-only; hidden on mobile).
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Homepage marketing content had drifted from both the current
education regime and the app's own school pages. An expert accuracy
review surfaced five issues (3 critical, 2 high):
- Ofsted card showed a single "OUTSTANDING" word-grade. Overall grades
were abolished (Sept 2024) and inspections now produce a report card
on a five-point scale (from Nov 2025). Rebuilt the card as a report
card with per-area ratings + stand-alone safeguarding, matching what
CompareOfsted actually renders on a school page.
- Attainment 8 "National avg 50.2" was wrong; the real 2024/25 figure
is 45.9. Corrected the benchmark and the illustrative school value.
- Compare mock featured a KS2 "Reading progress" score, but KS2
progress isn't published for 2023/24 or 2024/25 (no post-COVID KS1
baseline). Swapped for Higher standard (RWM), which is published.
- Editorial called it "UK school performance data"; the data is
England-only. Now matches the hero and page metadata.
- Softened the "40-page inspection report" exaggeration.
Landing-page copy isn't covered by e2e journeys, so no test changes.
Verified with tsc --noEmit and next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Drop the note's `max-width: 72ch`, which left it stopping well short of the
section's right edge. It now fills the results-section container like the
subtitle paragraph above it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
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
Final Ofsted/education-expert review of the detail page (Batch F) found one
clarity gap: on an all-through school the Admissions block shows a single
entry point's figures (Hessle's are the Year 7 secondary intake —
admissions.school_phase = "Secondary") but is titled just "Admissions", so a
parent interested in the reception/primary intake could read the Year 7
numbers as the whole school.
Add a phase subtitle for all-through schools ("These figures are for secondary
entry (Year 7)." / "...primary entry (Reception)."). Pure-primary and
pure-secondary pages are unambiguous and unchanged.
Rest of the review verified correct against source payloads: all three Ofsted
regimes (report card / ungraded post-Sept-2024 / carried-forward), England-
average anchoring, gap-honest trend charts (KS2 + KS4), all-through dual-phase
rendering, and the mobile layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Batch E of applying the compare-screen learnings to the school detail page —
all-through handling (point 11).
An all-through school carries both KS2 and KS4 figures in the same yearly
rows, but SchoolDetailView flipped it to isSecondary and rendered GCSE-only,
hiding the entire primary phase (SATs, phonics, KS2 trend). The Results
snapshot already gated its KS2/KS4 blocks purely on data availability, so both
already appeared there — but the section title, the trend chart, phonics, the
nav label and the history table all still assumed a single phase.
- Add an explicit `isAllThrough` flag (+ `showPrimaryContent = isPrimary ||
isAllThrough`); pure-secondary behaviour is unchanged.
- Hero: an "All-through (primary & secondary)" meta chip for all-ages framing.
- Results section: title "SATs & GCSE Results", a combined subtitle, and
"Primary — KS2 SATs (Year 6)" / "Secondary — GCSEs (Year 11)" sub-headings
separating the two blocks.
- Results Over Time: render two stacked PerformanceCharts (KS2 SATs + GCSEs)
rather than crowding both stages' series — on different scales with
different gap stories — onto one axis. Each gets its correct England overlay.
- Phonics section + nav item now show for all-through (primary-stage metric).
- History table: an all-through column set covering both phases (RWM, Exceeding,
Attainment 8, Progress 8, Eng & Maths 4+).
- Nav "Results" label for all-through instead of "GCSEs".
e2e: a new journey asserts an all-through school (Hessle, 137306) shows both
the KS2 and KS4 results and the All-through label, with a data-driven
precondition skip if staging data drifts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Applies the compare screen's chart-truthfulness rules (spec §8.1) to the
school detail page's time-series charts.
PerformanceChart (Results Over Time, both phases):
- Fill every academic year between the first and last data point via the
shared fillAcademicYears helper, so cancelled/unpublished years (2019/20,
2020/21, and — for KS2 — 2021/22) render as real gaps instead of
compressed time. Each series and the England overlay map onto this filled
axis with null for missing years; spanGaps:false so school lines never
bridge a gap.
- Replace the primary-only COVID note with a distinct, honest gap caption:
KS2 names the cancelled tests plus the unpublished 2021/22 school-level
year; KS4 names the unpublished 2019/20–2020/21 GCSE grading years.
- Mobile x-axis switches to autoSkip so the longer (gap-honest) axis stays
readable; the broken line still marks a missing year even when its tick
label is skipped.
AdmissionsTrendChart:
- Same gap-honest axis + spanGaps:false so a missing admissions year is a
real gap, not compressed time.
Point 12 (definite canvas heights): desktop is already a definite 280px;
fix the secondary detail's mobile .chartContainer, which fixed the outer
box at 220px and double-constrained PerformanceChart's own 220px canvas +
chip strip (clipping the chips onto the plot) — now height:auto to match
the primary view.
SatsChart is out of scope (single-year per-subject CSS bars — no year axis,
no canvas height to constrain).
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