From 9447b80bc6bfc802f7dd22266da2dbc7955c73db Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 19:13:18 +0100 Subject: [PATCH 01/11] docs: compare mockups as frontend design source + rebuild plan Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../2026-07-13-compare-frontend-rebuild.md | 287 ++++++++ .../specs/mockups/compare-desktop.html | 642 ++++++++++++++++++ .../specs/mockups/compare-mobile.html | 437 ++++++++++++ 3 files changed, 1366 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-compare-frontend-rebuild.md create mode 100644 docs/superpowers/specs/mockups/compare-desktop.html create mode 100644 docs/superpowers/specs/mockups/compare-mobile.html diff --git a/docs/superpowers/plans/2026-07-13-compare-frontend-rebuild.md b/docs/superpowers/plans/2026-07-13-compare-frontend-rebuild.md new file mode 100644 index 0000000..dcb165d --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-compare-frontend-rebuild.md @@ -0,0 +1,287 @@ +# Compare Screen Frontend Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild `/compare` in the Next.js app to match the approved mockups — parent-first sections (At a glance / Ofsted / Academics / Getting a place / Who goes there / Explore trends), England-average anchoring with provenance-correct labels, mobile-first measure-first layout — consuming the enriched `/api/compare` payload from PR #34, with e2e journeys updated in the same PR (they are the promotion gate). + +**Architecture:** `ComparisonView` becomes an assembly of section components fed by one enriched fetch. All comprehension rules from the two expert reviews live in a pure, jest-tested module (`lib/compareLogic.ts`) — components stay presentational. The mockups are committed at `docs/superpowers/specs/mockups/compare-desktop.html` and `compare-mobile.html`: **all user-facing copy (labels, tooltips, chips, footnote wording) is taken verbatim from them** — they carry two rounds of education-expert review; do not paraphrase. + +**Tech Stack:** Next.js (app router, SSR page + client view), CSS modules, Chart.js (existing `ComparisonChart`), Jest (`npm test` in `nextjs-app/`), Playwright e2e (`e2e/`). + +## Global Constraints + +- **Never push to `main`.** Branch: `feat/compare-frontend-rebuild`. +- **Copy is expert-reviewed:** take it verbatim from the committed mockups. Binding rules (spec §8): Ofsted scale labels come from the API's `report_card[..].label` (never hardcode area labels beyond the mockups'); official DfE numbers say "England average", computed ones say "state-school average (computed from our dataset)"; the 2021/22 chart gap note says "DfE didn't publish school-level figures for 2021/22"; never derive an overall grade from report-card areas; safeguarding never counts as a graded area; "Latest Ofsted inspection", "EHC plans", "at or above capacity", "Over 1 in 4", "first choice (officially 'first preference')". +- **Mobile-first:** the measure-first stacked layout (mobile mockup) is the base CSS; the desktop label-column grid is the `min-width` enhancement. +- **URL contract unchanged:** `?urns=` (and `metric=` now scoped to Explore trends) keep working; share flow, `useComparison` basket, phase tabs, and `compare_viewed`/`compare_metric_changed` analytics events are preserved. +- **Do not run a local server** (CLAUDE.md); verification = jest + `tsc` + the e2e suite against staging after merge. e2e must pass on **staging data** — remember staging has partial history: assert against the *latest* year, never oldest. +- Existing `/api/compare` consumers elsewhere in the app (SchoolDetail links, toasts) must not break — the response is additive, and this PR only rewrites the compare page's own components. +- **Post-v1 (do not build):** IDACI, attendance section, gender-split/absence tier-2 measures, finance (spec §4). + +--- + +### Task 0: Branch + design sources + +- [ ] `git checkout main && git pull && git checkout -b feat/compare-frontend-rebuild` +- [ ] The mockups and this plan are already in the working tree (`docs/superpowers/specs/mockups/compare-{desktop,mobile}.html`) — commit them: `docs: compare mockups as frontend design source + rebuild plan` + +--- + +### Task 1: Types for the enriched payload + +**Files:** +- Modify: `nextjs-app/lib/types.ts` (extend `SchoolResult`, `ComparisonData`, `ComparisonResponse` — located around lines 293-314) + +**Interfaces (produced for every later task):** + +```ts +export interface ReportCardEntry { code: number; label: string; } + +export interface OfstedBlock { + framework: string | null; + inspection_date: string | null; + inspection_type: string | null; + overall_effectiveness: number | null; + grade_source: 'graded' | 'ungraded_carried_forward' | null; + quality_of_education: number | null; + behaviour_attitudes: number | null; + personal_development: number | null; + leadership_management: number | null; + early_years_provision: number | null; + sixth_form_provision: number | null; + rc_safeguarding_met: boolean | null; + report_card: Record; + ofsted_page_url: string; + report_url: string | null; +} + +export interface CensusBlock { + year: number | null; total_pupils: number | null; + female_pupils: number | null; male_pupils: number | null; + fsm_pct: number | null; eal_pct: number | null; +} + +export interface AdmissionsRow { + year: number; school_phase: string | null; + places_offered: number | null; total_applications: number | null; + first_preference_applications: number | null; first_preference_offers: number | null; + first_preference_offer_pct: number | null; oversubscription_ratio: number | null; + oversubscribed: boolean | null; + total_offers: number | null; second_preference_offers: number | null; + third_preference_offers: number | null; + cross_la_applications: number | null; cross_la_offers: number | null; +} + +export interface DeprivationBlock { + lsoa_code: string | null; idaci_score: number | null; idaci_decile: number | null; +} + +export interface BenchmarkBlock { + eal_pct: number | null; sen_support_pct: number | null; + disadvantaged_pct: number | null; median_pupils: number | null; + disadvantaged_rwm_expected_pct?: number | null; +} + +export interface Benchmarks { + source: string; year: number; + primary: BenchmarkBlock; secondary: BenchmarkBlock; +} + +export interface NationalAverages { + year: number; + primary: Record; + secondary: Record; + by_year: Array<{ year: number; primary: Record; secondary: Record }>; +} +``` + +- [ ] **Step 1:** Add the interfaces above; extend `ComparisonData` with optional `ofsted?: OfstedBlock | null; census?: CensusBlock | null; admissions?: AdmissionsRow | null; admissions_history?: AdmissionsRow[]; deprivation?: DeprivationBlock | null;` and `ComparisonResponse` with `national_averages?: NationalAverages; benchmarks?: Benchmarks;` (optional so the UI degrades on an old backend). Extend `SchoolResult` with the ten new yearly columns (`reading_progress_lower_ci` … `maths_progress_upper_ci`, `writing_working_towards_pct`, `progress_8_banding: string | null`, `attainment_8_disadvantage_gap`, `progress_8_disadvantage_gap`). +- [ ] **Step 2:** `cd nextjs-app && npx tsc --noEmit` → clean. Commit: `feat(compare): types for enriched comparison payload` + +--- + +### Task 2: `lib/compareLogic.ts` — the comprehension rules, jest-tested + +**Files:** +- Create: `nextjs-app/lib/compareLogic.ts` +- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` + +**Interfaces (produced):** + +```ts +export type Verdict = 'above' | 'close' | 'below'; +export function verdict(value: number, anchor: number, tolerance?: number): Verdict; // default tolerance 2pp + +// Report-card summary per spec §4.2: count graded areas per label (best +// first), NAME any 'Needs attention'/'Urgent improvement' area, safeguarding +// separate, "No areas need attention" reassurance when applicable. +export interface ReportCardSummary { + counts: Array<{ label: string; count: number }>; // best grade first + problems: Array<{ areaLabel: string; label: string }>; // named, never counted-away + safeguarding: 'met' | 'not_met' | null; + allClear: boolean; +} +export function summariseReportCard(ofsted: OfstedBlock): ReportCardSummary; + +// One display model for all three inspection regimes. +export type OfstedDisplay = + | { kind: 'none' } + | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } + | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } + | { kind: 'report_card'; summary: ReportCardSummary }; +export function ofstedDisplay(ofsted: OfstedBlock | null | undefined): OfstedDisplay; +export const OFSTED_LEGACY_GRADES: Record; // 1 Outstanding, 2 Good, 3 Requires improvement, 4 Inadequate + +// Human-readable area label from an rc_ key: 'rc_attendance_behaviour' → +// 'Attendance & behaviour' (mapping table copied from the mockups' area rows). +export function rcAreaLabel(key: string): string; + +// Admissions, one consistent chip metric (first-preference success). +export interface AdmissionsSummary { + firstPrefPct: number | null; + chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null; // "97% of first choices offered" / "Over 1 in 4 first choices missed out" wording per mockups + interest: string | null; // "Named on 457 forms · 180 places" +} +export function summariseAdmissions(a: AdmissionsRow | null | undefined): AdmissionsSummary; + +// CI-based progress band for historical years (null when no CI published). +export function progressBand(score: number | null, lower: number | null, upper: number | null): + 'above' | 'average' | 'below' | null; // CI entirely >0 → above; entirely <0 → below; straddles → average + +// Dot-strip geometry (used by the DotStrip component; pure for testing). +export interface StripPoint { pos: number; labelAbove: boolean; value: number; schoolIndex: number; } +export function stripPositions(values: Array, min: number, max: number): StripPoint[]; +// pos = (v-min)/(max-min)*100 clamped 0..100; labels within 4% of range of a +// lower neighbour flip above (the mockups' collision nudge). +``` + +- [ ] **Step 1: Failing tests** covering, at minimum: + - `summariseReportCard`: 4 Strong + 2 Expected + 1 Needs-attention + safeguarding met → counts `[Strong standard×4, Expected standard×2]`, `problems=[{areaLabel:'Attendance & behaviour', label:'Needs attention'}]`, `allClear=false`; safeguarding NEVER in counts; all-Expected+met → `allClear=true`; labels come from the input's `.label` (assert the function never invents "Attention needed"). + - `ofstedDisplay`: report_card present → `kind:'report_card'` even if a legacy grade also exists; `grade_source:'ungraded_carried_forward'` → `carriedForward:true`; null → `'none'`. + - `summariseAdmissions`: 73% → warn chip text `Over 1 in 4 first choices missed out`; 97% → good chip `97% of first choices offered`; 100% → `All first choices offered`; interest string `Named on 342 forms · 120 places`; nulls → null chip. + - `progressBand`: (1.2, 0.4, 2.0)→above; (-1.2, -2.0, -0.4)→below; (0.3, -0.5, 1.1)→average; missing CI → null. + - `stripPositions`: 100–120 domain maps 106→30; values 91 and 92 on 0–100 → second label flips above; nulls skipped. + - `verdict`: 87 vs 62 → above; 61 vs 62 → close (within 2pp); 40 vs 62 → below. +- [ ] **Step 2:** `cd nextjs-app && npm test -- compareLogic` → FAIL. **Step 3:** implement. **Step 4:** pass + `tsc` clean. **Step 5:** Commit: `feat(compare): comprehension logic (report cards, admissions, verdicts, strips)` + +--- + +### Task 3: `DotStrip` component + +**Files:** +- Create: `nextjs-app/components/DotStrip.tsx`, `nextjs-app/components/DotStrip.module.css` + +**Interfaces:** + +```ts +export interface DotStripProps { + label: string; + values: Array; // one per school, school order = chart colour order + anchor?: { value: number; label: string } | null; // e.g. {62, "England 62%"} — omit when benchmark absent + min?: number; max?: number; // default 0..100 + unit?: string; // default '%' + tip?: string; // title tooltip on the label + note?: string; // e.g. "(teacher-assessed)" suffix handled by caller in label +} +``` + +- [ ] Render per the mockups' `.strip-row` anatomy: label row, 4px track, England tick + tick label, 16px dots coloured by `CHART_COLORS[index]` with white ring, value labels below (flipped above on collision via `stripPositions`). `role="img"` + `aria-label` enumerating anchor and each school's value (copy the aria pattern from the mockups). CSS module mirrors the mockup styles using the app's CSS variables (`--border-light`, `--text-muted`, etc.). +- [ ] Jest: render with `@testing-library/react` (already configured — see `__tests__/components/SecondarySchoolRow.test.tsx` for the harness pattern): asserts aria-label content, tick present when anchor given, absent otherwise. +- [ ] Commit: `feat(compare): DotStrip with England-average anchor` + +--- + +### Task 4: Section components — At a glance, Ofsted, Getting a place, Who goes there + +**Files:** +- Create: `nextjs-app/components/compare/CompareAtAGlance.tsx` (+ `.module.css`) +- Create: `nextjs-app/components/compare/CompareOfsted.tsx` +- Create: `nextjs-app/components/compare/CompareAdmissions.tsx` +- Create: `nextjs-app/components/compare/CompareCommunity.tsx` +- Create: `nextjs-app/components/compare/compareSections.module.css` (shared measure-first grid) +- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx` + +**Shared layout contract (all four):** props `{ schools: School[]; data: Record; benchmarks?: Benchmarks; nationalAverages?: NationalAverages }`. Base CSS is the mobile mockup's measure-first stack (`.measure` card → `.srow` per school with colour dot + short name + value + chip + note); at `min-width: 761px` it becomes the desktop mockup's grid (200px row-label column + one column per school). Section headers use the existing `.section-title` idiom; every section carries its mockup "how" line verbatim. + +**Content per section = the mockups, row for row.** Structure/tone rules already encoded in Task 2's helpers: +- *At a glance*: Latest Ofsted inspection row (badge via `ofstedDisplay`; report-card case renders `ReportCardSummary` chips — counts best-first + named problem chips + safeguarding line); expected-standard row (big % + `verdict` chip vs `national_averages.primary.rwm_expected_pct`, small "England average N%"); Getting a place row (chip from `summariseAdmissions`, note = `interest`); Size row (pupils + "at or above capacity"/"N% full" from census/capacity, vs `benchmarks.*.median_pupils` for "larger/smaller than average" phrasing). +- *Ofsted*: the section's `how` paragraph (regime explanation + non-comparability + "Expected standard" disambiguation) verbatim from the desktop mockup; Result row; Inspected row (date + "4+ years ago" chip when >4y, computed from `inspection_date`); Judgement detail row — **one chip-list grammar for both regimes** (legacy subgrades via `OFSTED_LEGACY_GRADES`; report card via `report_card` labels; "We don't hold area-by-area detail for this inspection" when neither); Ofsted page row linking `ofsted_page_url` ("'s Ofsted page →"). +- *Getting a place*: `how` paragraph (first preference/equal preference/offer-day caveats) verbatim; Interest row; first-choice success row with mini bar; "What this means" row (distance note: "check the school's admission criteria (for most non-faith primaries, distance decides)" only when oversubscribed). +- *Who goes there*: pupils-on-roll (census + capacity), girls/boys, FSM (chip vs `benchmarks` with "state-school average" wording), EAL, SEN (tooltip incl. "EHC plans" + specialist-provision note), faith, ages · nursery, run by (trust name or " council"). + +- [ ] **Step 1:** Failing jest test for `CompareOfsted` (the riskiest): given one graded school, one carried-forward, one report-card school → asserts the three Result cells ("Outstanding" badge; badge + carried-forward marker; "Report card" + no invented overall grade), the chip-list judgement rows, and the comparability note appearing only for the mixed case. +- [ ] **Step 2-4:** Implement all four sections; test passes; `tsc` clean; `npm test` full suite green. +- [ ] **Step 5:** Commit: `feat(compare): at-a-glance, Ofsted, admissions and community sections` + +--- + +### Task 5: `CompareAcademics` — strips + More measures + +**Files:** +- Create: `nextjs-app/components/compare/CompareAcademics.tsx` +- Test: extend `nextjs-app/__tests__/lib/compareLogic.test.ts` with the metric-extraction helper below + +**Interfaces:** +- Add to `compareLogic.ts`: `latestValues(data, urns, metricKey) => Array` (latest non-null yearly value per school) — tested. + +- [ ] Tier 1 strips (always visible), each a `DotStrip` with the England anchor from `national_averages.primary`: RWM expected, Reading, Writing, Maths, "Working at a higher standard than expected" (tooltip: composition sentence from the mockups). Section `how` line: "tests and teacher assessments … writing is assessed by teachers, not tested" verbatim. +- [ ] Tier 2 `
` "More measures — grammar, punctuation & spelling, science, average scaled scores": GPS + Science (teacher-assessed, tooltip verbatim) with anchors from `national_averages` **when present, no tick + honest note when null**; scaled scores (reading/maths/GPS) on `min=100 max=120` with the mockups' window caption. +- [ ] Equity row: disadvantaged pupils' RWM per school + chip vs `benchmarks.primary.disadvantaged_rwm_expected_pct` with the "state-school average" wording and small-cohort tooltip verbatim. +- [ ] Secondary phase variant (when active phase is secondary): tier-1 rows are Attainment 8 (anchor `national_averages.secondary.attainment_8_score`), Progress 8 banding (chip showing `progress_8_banding` verbatim — DfE's own label), grade 5+ English & maths %; tier-2: EBacc entry/APS. Measure-first rows (no strips needed for banding). +- [ ] `npm test` + `tsc`; commit: `feat(compare): academics strips with England anchors and More measures` + +--- + +### Task 6: Trends explorer — England line, gap-honest axis, series bug + +**Files:** +- Modify: `nextjs-app/components/ComparisonChart.tsx` +- Create: `nextjs-app/components/compare/TrendsExplorer.tsx` +- Test: `nextjs-app/__tests__/components/ComparisonChart.test.tsx` + +- [ ] **Step 1 (bug first): root-cause the missing third series** seen on production (3 schools in table, 2 lines on chart). Write a failing jest test: 3 schools whose `yearly_data` year values are floats (`202425.0`) vs the labels array — the suspect is the year-matching in `ComparisonChart.tsx:69` (`years.map(...)` built from school 1 only + strict equality against other schools' years). Fix so every school's series renders and years are the union of all schools' years, sorted. +- [ ] **Step 2:** Add optional `nationalByYear?: Record` prop → dashed grey "England average" dataset (colour `--text-muted`, `borderDash:[5,4]`, no fill, `spanGaps:false`). +- [ ] **Step 3:** Gap honesty: x-axis category labels include 2019/20 and 2020/21 as empty slots (band label "tests cancelled 2019/20–2020/21" via a Chart.js annotation-free approach: two category ticks with all-null data and a subtitle note under the chart, copy verbatim: the chart footnote "DfE didn't publish school-level figures for 2021/22" appears when the metric is a KS2 measure and 2021/22 school values are null while the England value exists). `spanGaps:false` on school datasets so dataset gaps break lines. +- [ ] **Step 4:** `TrendsExplorer` wraps the grouped metric picker (existing optgroup structure and `metrics` from `/api/metrics`, existing analytics event) + the chart + the existing year-by-year table, inside a collapsed-by-default `
` ("Explore trends"). Progress metrics annotate cells with `progressBand` chips for years where CIs exist. +- [ ] Tests pass; commit: `feat(compare): trends explorer with England line; fix missing series` + +--- + +### Task 7: Assemble the new `ComparisonView` + +**Files:** +- Rewrite: `nextjs-app/components/ComparisonView.tsx` (+ its `.module.css`) +- Modify: `nextjs-app/app/compare/page.tsx` metadata description (mention Ofsted/admissions, not just KS2) + +- [ ] Preserve intact: `useComparison` basket seeding/URL sync (lines 76-122 of the current file), share handler, phase tabs + auto-detection, `compare_viewed` analytics, empty states, `SchoolSearchModal`, max-4-visible column scroll. Replace the metric-picker/chart/table body with the section stack: sticky school chip bar (mockup `.school-bar`) → `CompareAtAGlance` → `CompareOfsted` → `CompareAcademics` → `CompareAdmissions` → `CompareCommunity` → `TrendsExplorer`. The page-level `metric` URL param now initialises `TrendsExplorer`'s picker only. +- [ ] Top-of-page subtitle + sources footnote verbatim from the mockups (minus the "Mockup" banner), including the suppression rule sentence and provenance sentence. +- [ ] `npm test` full suite + `tsc` clean. Commit: `feat(compare): parent-first compare screen assembly` + +--- + +### Task 8: e2e journeys (the promotion gate) + +**Files:** +- Modify: `e2e/tests/journeys.spec.ts` (the two compare tests, lines ~141-215; extend, don't delete coverage) + +- [ ] Update 'comparing two schools shows both side by side': after loading `/compare?urns=…` assert the new section headings (`At a glance`, `Ofsted inspection`, `How children do academically`, `Getting a place`, `Who goes there`, `Explore trends`), both school names in the sticky bar, at least one England-average tick label (`text=/England \d+%/`), and one provenance string `state-school average` somewhere (benchmarks row). Data-invariant style — no exact numbers (staging data shifts; use latest-year values only). +- [ ] Update the mobile test: 390px viewport, assert measure-first stacking (a `.measure`-card contains all selected school names within one card) and that the trends chart container scrolls (`overflow-x`). +- [ ] Add a report-card presence-agnostic assertion: the Ofsted section renders either a grade badge or "Report card" without an overall grade — i.e. never both an overall-grade badge AND report-card chips for the same school. +- [ ] Run against staging from the host if reachable (`cd e2e && BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "compar"`) — staging still runs the OLD UI until this PR merges, so expect failures locally; the authoritative run is the Stage pipeline post-merge. Still commit only after jest+tsc are green. +- [ ] Commit: `test(e2e): compare journeys for the parent-first redesign` + +--- + +### Task 9: PR + post-merge verification + +- [ ] Full gates: `cd nextjs-app && npm test && npx tsc --noEmit`. +- [ ] Push; open PR via Gitea API (credential-helper basic auth). PR body: before/after summary, link to mockups + spec §4/§8, the copy-verbatim rule, the fixed third-series bug, deploy note (needs PR #34's API on the same environment — merge order: #34 first), and that the e2e suite is the staging gate. +- [ ] Post-merge: watch the Stage pipeline — its e2e run against staging is the real verification. Then the human tests staging and promotes (two-stage model). Update memory: compare redesign shipped to staging. + +--- + +## Out of scope + +- IDACI / attendance / gender-absence / finance (post-v1, spec §4). +- Backend changes of any kind (PR #34 must merge first). +- Chart palette overhaul beyond the England-line addition (`CHART_COLORS` swap to the validated trio is a candidate follow-up, flagged not included — it affects every chart in the app). diff --git a/docs/superpowers/specs/mockups/compare-desktop.html b/docs/superpowers/specs/mockups/compare-desktop.html new file mode 100644 index 0000000..e4c9d06 --- /dev/null +++ b/docs/superpowers/specs/mockups/compare-desktop.html @@ -0,0 +1,642 @@ +Compare screen — proposed redesign + + +
+

Mockup — proposed redesign of /compare. All figures are live production data for three real schools (2024/25 results, 2026/27 admissions round). England averages for test results are official DfE figures; other benchmarks are state-school averages computed from our dataset.

+ +

Compare schools

+

Three schools side by side — inspection results, academics, admissions and community, each anchored against the England average so you can tell at a glance what's typical and what stands out.

+ +
+
+ + Barclay Primary School
Waltham Forest · Academy
+ +
+
+ + Elmhurst Primary School
Newham · Academy
+ +
+
+ + Plumcroft Primary School
Greenwich · Community school
+ +
+ +
+ + +
+

At a glance

+

The short version — each row below is explained in its own section further down.

+
+
Latest Ofsted inspection
+
OutstandingInspected Oct 2021
+
OutstandingInspected Oct 2021
+
+ Report card illustrative +
+ 4 areas Strong standard + 2 areas Expected standard + Attendance & behaviour: Attention needed +
+ Safeguarding met · Nov 2025 +
+ +
Children reaching the expected standard ?
+
87% Above England averageEngland average 62%
+
92% Above England averageEngland average 62%
+
79% Above England averageEngland average 62%
+ +
Getting a place
+
97% of first choices offeredNamed on 457 forms · 180 places
+
73% of first choices offeredNamed on 342 forms · 120 places
+
All first choices offeredNamed on 185 forms · 80 places
+ +
Size
+
1,273 pupilsMuch larger than average
+
980 pupilsMuch larger than average
+
1,056 pupilsMuch larger than average
+
+
+ + +
+

Ofsted inspection

+

Ofsted is the schools inspectorate. It stopped giving a single overall grade in September 2024; inspections between then and November 2025 kept the area-by-area judgements without an overall grade, and from November 2025 new inspections produce a report card rating each area of school life on a five-point scale (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older overall grade aren't directly comparable — Plumcroft's report card below is an illustrative example of the new format, as no school in our dataset has one yet. (Ofsted's "Expected standard" rating is unrelated to the KS2 "expected standard" test measure further down this page.)

+
+
Result
+
OutstandingOverall grade (older-style inspection)
+
OutstandingOverall grade (older-style inspection)
+
Report card illustrativeNew-style inspection — no overall grade is given
+ +
Inspected
+
7 Oct 2021 4+ years ago
+
6 Oct 2021 4+ years ago
+
14 Nov 2025
+ +
Judgement detail ?
+
We don't hold area-by-area detail for this inspection — see Barclay's Ofsted page for the full report.
+
+
+
Quality of educationOutstanding
+
Behaviour & attitudesOutstanding
+
Personal developmentOutstanding
+
Leadership & managementOutstanding
+
+
+
+
+
AchievementStrong standard
+
Curriculum & teachingStrong standard
+
Attendance & behaviourAttention needed
+
Personal developmentStrong standard
+
InclusionExpected standard
+
Leadership & governanceStrong standard
+
Early yearsExpected standard
+
SafeguardingMet
+
+
+ +
Ofsted page ?
+ + + +
+
+ + +
+

How children do academically

+

Results from national tests and teacher assessments at the end of Year 6 (2024/25) — writing is assessed by teachers, not tested. Each line runs from 0–100%; the grey tick marks the England average, so dots to its right are above average.

+
+
+
+ More measures — grammar, punctuation & spelling, science, average scaled scores +
+

The strips show the 100–120 window of the full 80–120 scaled-score range; 100 is the expected standard, and the strip widens if a school averages below it. England ticks for grammar, punctuation & spelling and science aren't in our dataset yet, and the scaled-score England ticks are indicative — official DfE figures for all of these will be loaded before launch.

+
+
+
+ +
+
Trend, 2015/16 to 2024/25 ?
+
Variable, recently 87%
+
Consistently high
+
Improving since 2022/23
+ +
Children from lower-income families ?
+
86% Well above the 46% state-school average
+
93% Well above the 46% state-school average
+
72% Above the 46% state-school average
+
+
+ + +
+

Getting a place

+

From the most recent admissions round (September 2026 entry). "First choice" means families who ranked the school top of their application form — officially a "first preference". Schools never see your ranking: places are decided only by the school's admission criteria, so listing a school lower down never hurts your chances. These are National Offer Day offers — waiting lists and appeals can change the final intake.

+
+
Interest in the school ?
+
Named on 457 forms · 180 places
+
Named on 342 forms · 120 places
+
Named on 185 forms · 80 places
+ +
First-choice families offered a place
+
97%
+
73% Over 1 in 4 first choices missed out
+
100%
+ +
What this means
+
Nearly every family who put Barclay first got a place.
+
More first-choice applications than places — check the school's admission criteria (for most non-faith primaries, distance decides).
+
Every family who put Plumcroft first got a place.
+
+
+ + +
+

Who goes there

+

The school's community, from the latest school census (2025/26). England averages are shown for context — there's no "right" number here.

+
+
Pupils on roll
+
1,273 1,260 places — at or above capacity
+
980 of 996 places (98% full)
+
1,056 1,050 places — at or above capacity
+ +
Girls / boys
+
51% / 49%
+
48% / 52%
+
51% / 49%
+ +
Free school meals ?
+
26% About the state-school average
+
25% About the state-school average
+
30% A little above average
+ +
English as an additional language ?
+
62%
+
84%
+
20%
+ +
Extra learning support (SEN) ?
+
6%
+
8%
+
28% Well above average
+ +
Faith character
+
None
+
None
+
None
+ +
Ages · nursery
+
3–11 · has a nursery
+
3–11 · has a nursery
+
3–11 · has a nursery
+ +
Run by
+
Lion Academy Trust
+
New Vision Trust
+
Greenwich council
+
+
+ + +
+

Explore trends

+

The full year-by-year explorer — every measure from the current compare page lives on here, grouped, each with its England-average line. Three measures are wired up in this mockup; the rest are shown to convey the catalogue.

+
+ Year-by-year trends, 2015/16 to 2024/25 +
+
+ + + School lines break where a year isn't in our dataset. +
+ +
+
+
+
+ +

+ Sources: DfE Compare School Performance (KS2 results), Ofsted inspection outcomes, DfE school admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are the official DfE national figures; benchmarks for free school meals, language, SEN, school size and disadvantaged pupils' results are computed across all state schools in our dataset. Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". This is a static mockup: tooltips and "Add school" are illustrative, and Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023) — no school in our dataset has a report card yet. +

+
+ + diff --git a/docs/superpowers/specs/mockups/compare-mobile.html b/docs/superpowers/specs/mockups/compare-mobile.html new file mode 100644 index 0000000..fbb73f1 --- /dev/null +++ b/docs/superpowers/specs/mockups/compare-mobile.html @@ -0,0 +1,437 @@ +Compare screen — mobile mockup + + +
+

Mobile mockup — proposed /compare. Mobile-first layout: measures stack vertically with all schools under each, so nothing needs horizontal swiping. Same live data as the desktop mockup.

+ +

Compare schools

+

Anchored against the England average — the grey tick — so you can tell what's typical at a glance.

+ +
+ Barclay + Elmhurst + Plumcroft + + Add +
+ +

At a glance

+

The short version — each measure is explained in its own section below.

+ +
+
Latest Ofsted inspection
+
BarclayOutstandingOlder-style inspection, Oct 2021
+
ElmhurstOutstandingOlder-style inspection, Oct 2021
+
Plumcroft4 areas Strong standard 2 areas Expected Attendance & behaviour: Attention needed illustrativeNew-style report card, Nov 2025 · safeguarding met · full detail in the Ofsted section below
+
+ +
+
Children reaching the expected standard ?
+
England average: 62%
+
Barclay87% Above average
+
Elmhurst92% Above average
+
Plumcroft79% Above average
+
+ +
+
Getting a place
+
Barclay97% of first choices offeredNamed on 457 forms · 180 places
+
Elmhurst73% of first choices offeredNamed on 342 forms · 120 places
+
PlumcroftAll first choices offeredNamed on 185 forms · 80 places
+
+ +

Ofsted inspection

+

Ofsted stopped giving a single overall grade in September 2024 (inspections until November 2025 kept the area-by-area judgements); from November 2025 new inspections produce a report card rating each area of school life (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older grade aren't directly comparable. Ofsted's "Expected standard" rating is unrelated to the KS2 test measure below.

+ +
+
Latest inspection
+
BarclayOutstanding4+ years ago7 Oct 2021 · we don't hold area-by-area detail for this inspection · Ofsted page →
+
ElmhurstOutstanding 4+ years ago 6 Oct 2021 +
+
Quality of educationOutstanding
+
Behaviour & attitudesOutstanding
+
Personal developmentOutstanding
+
Leadership & managementOutstanding
+
+ Ofsted page → +
+
PlumcroftReport card illustrative 14 Nov 2025 +
+
AchievementStrong standard
+
Curriculum & teachingStrong standard
+
Attendance & behaviourAttention needed
+
Personal developmentStrong standard
+
InclusionExpected standard
+
Leadership & governanceStrong standard
+
Early yearsExpected standard
+
SafeguardingMet
+
+ Ofsted page → +
+
+ +

How children do academically

+

End of Year 6 national tests and teacher assessments (2024/25) — writing is teacher-assessed. Each line runs 0–100%; the grey tick is the England average.

+
+
+ More measures — grammar, punctuation & spelling, science, scaled scores +
+

Strips show the 100–120 window of the full 80–120 scaled-score range; 100 is the expected standard (the strip widens if a school averages below it). England ticks for GPS and science aren't in our dataset yet, and the scaled-score ticks are indicative — official DfE figures will be loaded before launch.

+
+
+ +
+
Children from lower-income families ?
+
State-school average: 46%
+
Barclay86% Well above average
+
Elmhurst93% Well above average
+
Plumcroft72% Above average
+
+ +

Getting a place

+

September 2026 entry. "First choice" = families who ranked the school top of their form (officially a "first preference"). Schools never see your ranking — places go by the admission criteria alone. Figures are National Offer Day offers; waiting lists and appeals can change the final intake.

+
+
First-choice families offered a place
+
Barclay97%Named on 457 forms · 180 places
+
Elmhurst73% Over 1 in 4 missed outNamed on 342 forms · 120 places — check the school's admission criteria (for most non-faith primaries, distance decides)
+
Plumcroft100%Named on 185 forms · 80 places · every first choice offered
+
+ +

Who goes there

+

From the latest school census (2025/26). No "right" numbers here — just context.

+
+
Pupils on roll
+
Barclay1,273At or above capacity · much larger than average · girls 51% / boys 49%
+
Elmhurst98098% full · much larger than average · girls 48% / boys 52%
+
Plumcroft1,056At or above capacity · much larger than average · girls 51% / boys 49%
+
+
+
Free school meals ?
+
State-school average: 25% (our dataset)
+
Barclay26% About average
+
Elmhurst25% About average
+
Plumcroft30% A little above
+
+
+
English as an additional language · extra learning support (SEN) ?
+
BarclayEAL 62% · SEN 6%
+
ElmhurstEAL 84% · SEN 8%
+
PlumcroftEAL 20% · SEN 28% SEN well above avg
+
+
+
Basics
+
BarclayAges 3–11 · nursery · no faith · Lion Academy Trust
+
ElmhurstAges 3–11 · nursery · no faith · New Vision Trust
+
PlumcroftAges 3–11 · nursery · no faith · Greenwich council
+
+ +

Explore trends

+

Every measure from the current compare page lives on here, grouped. Three are wired up in this mockup. School lines break where a year isn't in our dataset.

+
+ +
+
+ +
+
+

← swipe the chart →

+ +

+ Sources: DfE Compare School Performance, Ofsted inspection outcomes, DfE admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are official DfE figures; FSM, language, SEN, size and disadvantaged-pupil benchmarks are computed across state schools in our dataset. Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023). Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". Static mockup — tooltips and "+ Add" are illustrative. +

+
+ + From 80f057ea5a8d5ec8a5946d3104bfde99257dac6e Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 19:14:55 +0100 Subject: [PATCH 02/11] feat(compare): types for enriched comparison payload Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/lib/types.ts | 74 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index c29512c..3634f87 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -99,6 +99,21 @@ export interface OfstedInspection { rc_leadership_governance: number | null; rc_early_years: number | null; rc_sixth_form: number | null; + /** Where the effective overall grade came from: a graded (Section 5) + * inspection, or carried forward from an ungraded (Section 8) outcome. */ + grade_source?: 'graded' | 'ungraded_carried_forward' | null; + /** Renewed-framework (Nov 2025) area judgements, coded + labelled by the + * backend from the live-sampled Ofsted vocabulary. Empty when the school + * has no report-card inspection. Safeguarding is never included here. */ + report_card?: Record; + /** The school's page on ofsted.gov.uk (never a deep report link). */ + ofsted_page_url?: string; + report_url?: string | null; +} + +export interface ReportCardEntry { + code: number; + label: string; } export interface SchoolCensus { @@ -129,6 +144,12 @@ export interface SchoolAdmissions { /** 1st-preference applications per place offered (>1 means oversubscribed). */ oversubscription_ratio?: number | null; oversubscribed: boolean | null; + total_offers?: number | null; + second_preference_offers?: number | null; + third_preference_offers?: number | null; + /** Applications naming this school from families in another LA, and offers to them. */ + cross_la_applications?: number | null; + cross_la_offers?: number | null; } export interface SenDetail { @@ -172,6 +193,22 @@ export interface SchoolResult { school_id: number; year: number; + // Progress confidence intervals + writing working-towards (published for + // years with progress measures, i.e. up to 2022/23) + reading_progress_lower_ci?: number | null; + reading_progress_upper_ci?: number | null; + writing_progress_lower_ci?: number | null; + writing_progress_upper_ci?: number | null; + writing_working_towards_pct?: number | null; + maths_progress_lower_ci?: number | null; + maths_progress_upper_ci?: number | null; + + // KS4 banding and disadvantage gaps + /** DfE's own plain-English Progress 8 label, e.g. "Well above average". */ + progress_8_banding?: string | null; + attainment_8_disadvantage_gap?: number | null; + progress_8_disadvantage_gap?: number | null; + // Pupil numbers total_pupils: number | null; eligible_pupils: number | null; @@ -308,10 +345,47 @@ export interface SchoolDetailsResponse { export interface ComparisonData { school_info: School; yearly_data: SchoolResult[]; + // Supplementary blocks (additive; absent on an old backend) + ofsted?: OfstedInspection | null; + census?: SchoolCensus | null; + admissions?: SchoolAdmissions | null; + admissions_history?: SchoolAdmissions[]; + deprivation?: SchoolDeprivation | null; +} + +export interface BenchmarkBlock { + eal_pct: number | null; + sen_support_pct: number | null; + disadvantaged_pct: number | null; + median_pupils: number | null; + /** Primary only — weighted by cohort size. */ + disadvantaged_rwm_expected_pct?: number | null; +} + +/** Computed from our dataset — NOT official DfE figures. UI copy must say + * "state-school average (computed from our dataset)" (the `source` string). */ +export interface Benchmarks { + source: string; + year: number; + primary: BenchmarkBlock; + secondary: BenchmarkBlock; +} + +export interface NationalAverages { + year: number; + primary: Record; + secondary: Record; + by_year: Array<{ + year: number; + primary: Record; + secondary: Record; + }>; } export interface ComparisonResponse { comparison: Record; + national_averages?: NationalAverages; + benchmarks?: Benchmarks; } export interface RankingItem { From 48ca042b0853aefc6300a0c2bb5c7e03e09511b8 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 19:17:15 +0100 Subject: [PATCH 03/11] feat(compare): comprehension logic (report cards, admissions, verdicts, strips) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/__tests__/lib/compareLogic.test.ts | 231 +++++++++++++++++ nextjs-app/lib/compareLogic.ts | 244 ++++++++++++++++++ 2 files changed, 475 insertions(+) create mode 100644 nextjs-app/__tests__/lib/compareLogic.test.ts create mode 100644 nextjs-app/lib/compareLogic.ts diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts new file mode 100644 index 0000000..d181a5d --- /dev/null +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -0,0 +1,231 @@ +/** + * compareLogic encodes the expert-reviewed comprehension rules for the + * compare screen: report-card summarisation (safeguarding never counted), + * three-regime Ofsted display, one consistent admissions chip metric, + * CI-based progress banding, verdict chips and dot-strip geometry. + */ + +import { + OFSTED_LEGACY_GRADES, + ofstedDisplay, + progressBand, + rcAreaLabel, + stripPositions, + summariseAdmissions, + summariseReportCard, + verdict, +} from '@/lib/compareLogic'; +import type { OfstedInspection, SchoolAdmissions } from '@/lib/types'; + +function ofsted(partial: Partial): OfstedInspection { + return { + framework: null, + inspection_date: null, + inspection_type: null, + overall_effectiveness: null, + quality_of_education: null, + behaviour_attitudes: null, + personal_development: null, + leadership_management: null, + early_years_provision: null, + previous_overall: null, + rc_safeguarding_met: null, + rc_inclusion: null, + rc_curriculum_teaching: null, + rc_achievement: null, + rc_attendance_behaviour: null, + rc_personal_development: null, + rc_leadership_governance: null, + rc_early_years: null, + rc_sixth_form: null, + ...partial, + }; +} + +const REPORT_CARD = { + rc_achievement: { code: 2, label: 'Strong standard' }, + rc_curriculum_teaching: { code: 2, label: 'Strong standard' }, + rc_personal_development: { code: 2, label: 'Strong standard' }, + rc_leadership_governance: { code: 2, label: 'Strong standard' }, + rc_inclusion: { code: 3, label: 'Expected standard' }, + rc_early_years: { code: 3, label: 'Expected standard' }, + rc_attendance_behaviour: { code: 4, label: 'Needs attention' }, +}; + +describe('summariseReportCard', () => { + it('counts graded areas best-first and NAMES problem areas', () => { + const s = summariseReportCard( + ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }), + ); + expect(s.counts).toEqual([ + { label: 'Strong standard', count: 4 }, + { label: 'Expected standard', count: 2 }, + ]); + expect(s.problems).toEqual([ + { areaLabel: 'Attendance & behaviour', label: 'Needs attention' }, + ]); + expect(s.safeguarding).toBe('met'); + expect(s.allClear).toBe(false); + }); + + it('never counts safeguarding as a graded area', () => { + const s = summariseReportCard( + ofsted({ + report_card: { rc_achievement: { code: 3, label: 'Expected standard' } }, + rc_safeguarding_met: true, + }), + ); + const total = s.counts.reduce((n, c) => n + c.count, 0); + expect(total).toBe(1); + }); + + it('is allClear when everything is Expected standard or better and safeguarding met', () => { + const s = summariseReportCard( + ofsted({ + report_card: { + rc_achievement: { code: 3, label: 'Expected standard' }, + rc_inclusion: { code: 1, label: 'Exceptional' }, + }, + rc_safeguarding_met: true, + }), + ); + expect(s.allClear).toBe(true); + expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 }); + }); + + it('passes labels through from the API — never invents wording', () => { + const s = summariseReportCard( + ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }), + ); + expect(JSON.stringify(s)).not.toContain('Attention needed'); + }); +}); + +describe('ofstedDisplay', () => { + it('prefers the report card over any legacy grade', () => { + const d = ofstedDisplay( + ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }), + ); + expect(d.kind).toBe('report_card'); + }); + + it('distinguishes graded from carried-forward grades', () => { + const graded = ofstedDisplay( + ofsted({ overall_effectiveness: 1, grade_source: 'graded' }), + ); + expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false }); + + const carried = ofstedDisplay( + ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }), + ); + expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true }); + }); + + it('handles missing data', () => { + expect(ofstedDisplay(null).kind).toBe('none'); + expect(ofstedDisplay(ofsted({})).kind).toBe('none'); + }); + + it('uses the four legacy grade words', () => { + expect(OFSTED_LEGACY_GRADES).toEqual({ + 1: 'Outstanding', + 2: 'Good', + 3: 'Requires improvement', + 4: 'Inadequate', + }); + }); +}); + +describe('rcAreaLabel', () => { + it('maps rc keys to the mockups’ area labels', () => { + expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour'); + expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching'); + expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance'); + }); +}); + +describe('summariseAdmissions', () => { + function admissions(partial: Partial): SchoolAdmissions { + return { + year: 202627, + places_offered: null, + total_applications: null, + first_preference_offer_pct: null, + oversubscribed: null, + ...partial, + }; + } + + it('97% → good chip with the mockup wording', () => { + const s = summariseAdmissions( + admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }), + ); + expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' }); + expect(s.interest).toBe('Named on 457 forms · 180 places'); + }); + + it('73% → warn chip "Over 1 in 4 first choices missed out"', () => { + const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 })); + expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' }); + }); + + it('100% → "All first choices offered"', () => { + const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 })); + expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' }); + }); + + it('no data → null chip and interest', () => { + const s = summariseAdmissions(null); + expect(s.chip).toBeNull(); + expect(s.interest).toBeNull(); + }); +}); + +describe('progressBand', () => { + it('CI entirely above zero → above', () => { + expect(progressBand(1.2, 0.4, 2.0)).toBe('above'); + }); + it('CI entirely below zero → below', () => { + expect(progressBand(-1.2, -2.0, -0.4)).toBe('below'); + }); + it('CI straddling zero → average', () => { + expect(progressBand(0.3, -0.5, 1.1)).toBe('average'); + }); + it('missing CI → null (no naive thresholding)', () => { + expect(progressBand(1.2, null, null)).toBeNull(); + expect(progressBand(null, null, null)).toBeNull(); + }); +}); + +describe('verdict', () => { + it('above / close / below with a 2pp tolerance', () => { + expect(verdict(87, 62)).toBe('above'); + expect(verdict(61, 62)).toBe('close'); + expect(verdict(40, 62)).toBe('below'); + }); +}); + +describe('stripPositions', () => { + it('maps a custom domain', () => { + const pts = stripPositions([106], 100, 120); + expect(pts[0].pos).toBe(30); + }); + + it('flips a colliding label above', () => { + const pts = stripPositions([91, 92], 0, 100); + const sorted = [...pts].sort((a, b) => a.value - b.value); + expect(sorted[0].labelAbove).toBe(false); + expect(sorted[1].labelAbove).toBe(true); + }); + + it('skips nulls and keeps school indices', () => { + const pts = stripPositions([50, null, 70], 0, 100); + expect(pts).toHaveLength(2); + expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]); + }); + + it('clamps out-of-domain values', () => { + const pts = stripPositions([95], 100, 120); + expect(pts[0].pos).toBe(0); + }); +}); diff --git a/nextjs-app/lib/compareLogic.ts b/nextjs-app/lib/compareLogic.ts new file mode 100644 index 0000000..3d4134f --- /dev/null +++ b/nextjs-app/lib/compareLogic.ts @@ -0,0 +1,244 @@ +/** + * Comprehension rules for the compare screen, kept pure and unit-tested. + * + * These encode the expert-review requirements (spec §8 of the compare + * redesign): report-card summaries count graded areas only (safeguarding is + * a separate binary judgement), problem areas are always NAMED rather than + * folded into counts, grade labels pass through from the API (live-sampled + * Ofsted vocabulary — never invented here), admissions chips use one + * consistent metric, and progress bands follow DfE's confidence-interval + * methodology instead of thresholding point estimates. + */ + +import type { OfstedInspection, SchoolAdmissions } from './types'; + +// --------------------------------------------------------------------------- +// Verdicts against an anchor (England average or state-school benchmark) +// --------------------------------------------------------------------------- + +export type Verdict = 'above' | 'close' | 'below'; + +export function verdict(value: number, anchor: number, tolerance = 2): Verdict { + if (value >= anchor + tolerance) return 'above'; + if (value <= anchor - tolerance) return 'below'; + return 'close'; +} + +// --------------------------------------------------------------------------- +// Ofsted — three regimes, one display model +// --------------------------------------------------------------------------- + +export const OFSTED_LEGACY_GRADES: Record = { + 1: 'Outstanding', + 2: 'Good', + 3: 'Requires improvement', + 4: 'Inadequate', +}; + +/** rc_ key → the area label used across the reviewed mockups. */ +const RC_AREA_LABELS: Record = { + rc_inclusion: 'Inclusion', + rc_curriculum_teaching: 'Curriculum & teaching', + rc_achievement: 'Achievement', + rc_attendance_behaviour: 'Attendance & behaviour', + rc_personal_development: 'Personal development', + rc_leadership_governance: 'Leadership & governance', + rc_early_years: 'Early years', + rc_sixth_form: 'Sixth form', +}; + +export function rcAreaLabel(key: string): string { + return RC_AREA_LABELS[key] ?? key; +} + +export interface ReportCardSummary { + /** Graded areas only, grouped by label, best grade first. */ + counts: Array<{ label: string; count: number }>; + /** Areas rated Needs attention / Urgent improvement — always named. */ + problems: Array<{ areaLabel: string; label: string }>; + safeguarding: 'met' | 'not_met' | null; + /** True when every graded area is Expected standard or better and + * safeguarding is not "not met". */ + allClear: boolean; +} + +const PROBLEM_CODES = new Set([4, 5]); + +export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary { + const entries = Object.entries(ofsted.report_card ?? {}); + const byCode = new Map(); + const problems: ReportCardSummary['problems'] = []; + + for (const [key, entry] of entries) { + if (PROBLEM_CODES.has(entry.code)) { + problems.push({ areaLabel: rcAreaLabel(key), label: entry.label }); + } else { + const existing = byCode.get(entry.code); + if (existing) existing.count += 1; + else byCode.set(entry.code, { label: entry.label, count: 1 }); + } + } + + const counts = [...byCode.entries()] + .sort(([a], [b]) => a - b) + .map(([, v]) => v); + + const safeguarding = + ofsted.rc_safeguarding_met === true + ? 'met' + : ofsted.rc_safeguarding_met === false + ? 'not_met' + : null; + + return { + counts, + problems, + safeguarding, + allClear: entries.length > 0 && problems.length === 0 && safeguarding !== 'not_met', + }; +} + +export type OfstedDisplay = + | { kind: 'none' } + | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } + | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } + | { kind: 'report_card'; summary: ReportCardSummary }; + +export function ofstedDisplay( + ofsted: OfstedInspection | null | undefined, +): OfstedDisplay { + if (!ofsted) return { kind: 'none' }; + + // A report card is the newest inspection format; when present it wins — + // never derive or prefer an overall grade alongside it. + if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) { + return { kind: 'report_card', summary: summariseReportCard(ofsted) }; + } + + const grade = ofsted.overall_effectiveness; + const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined; + if (grade == null || gradeLabel === undefined) return { kind: 'none' }; + + if (ofsted.grade_source === 'ungraded_carried_forward') { + return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; + } + return { kind: 'graded', grade, gradeLabel, carriedForward: false }; +} + +// --------------------------------------------------------------------------- +// Admissions — one consistent chip metric (first-preference success) +// --------------------------------------------------------------------------- + +export interface AdmissionsSummary { + firstPrefPct: number | null; + chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null; + /** e.g. "Named on 457 forms · 180 places" — total preferences at any rank, + * deliberately not phrased as head-to-head applications. */ + interest: string | null; +} + +export function summariseAdmissions( + a: SchoolAdmissions | null | undefined, +): AdmissionsSummary { + if (!a) return { firstPrefPct: null, chip: null, interest: null }; + + const pct = + a.first_preference_offer_pct != null + ? Math.round(a.first_preference_offer_pct) + : null; + + let chip: AdmissionsSummary['chip'] = null; + if (pct != null) { + if (pct >= 100) { + chip = { tone: 'good', text: 'All first choices offered' }; + } else if (pct < 75) { + chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' }; + } else { + chip = { tone: pct >= 90 ? 'good' : 'neutral', text: `${pct}% of first choices offered` }; + } + } + + const interest = + a.total_applications != null && a.places_offered != null + ? `Named on ${a.total_applications.toLocaleString('en-GB')} forms · ${a.places_offered.toLocaleString('en-GB')} places` + : null; + + return { firstPrefPct: pct, chip, interest }; +} + +// --------------------------------------------------------------------------- +// Progress bands — DfE confidence-interval methodology +// --------------------------------------------------------------------------- + +export function progressBand( + score: number | null, + lower: number | null, + upper: number | null, +): 'above' | 'average' | 'below' | null { + if (score == null || lower == null || upper == null) return null; + if (lower > 0) return 'above'; + if (upper < 0) return 'below'; + return 'average'; +} + +// --------------------------------------------------------------------------- +// Dot-strip geometry +// --------------------------------------------------------------------------- + +export interface StripPoint { + /** 0–100 percentage position along the track. */ + pos: number; + labelAbove: boolean; + value: number; + schoolIndex: number; +} + +/** Labels within 4% of the domain of a lower neighbour flip above the strip + * (the reviewed mockups' collision nudge). */ +export function stripPositions( + values: Array, + min = 0, + max = 100, +): StripPoint[] { + const span = max - min; + const points = values + .map((value, schoolIndex) => ({ value, schoolIndex })) + .filter((p): p is { value: number; schoolIndex: number } => p.value != null) + .map((p) => ({ + value: p.value, + schoolIndex: p.schoolIndex, + pos: Math.min(100, Math.max(0, ((p.value - min) / span) * 100)), + labelAbove: false, + })); + + const nudge = span * 0.04; + let lastBelow = -Infinity; + for (const p of [...points].sort((a, b) => a.value - b.value)) { + if (p.value - lastBelow < nudge) { + p.labelAbove = true; + } else { + lastBelow = p.value; + } + } + return points; +} + +// --------------------------------------------------------------------------- +// Metric extraction +// --------------------------------------------------------------------------- + +/** Latest non-null yearly value of `metricKey` per school, in `urns` order. */ +export function latestValues( + data: Record & { year: number }> }>, + urns: number[], + metricKey: string, +): Array { + return urns.map((urn) => { + const rows = data[String(urn)]?.yearly_data ?? []; + for (let i = rows.length - 1; i >= 0; i--) { + const v = rows[i][metricKey]; + if (typeof v === 'number' && !Number.isNaN(v)) return v; + } + return null; + }); +} From 60cbc3f46dcd35da9d3f51e15a77bb5e08ef5b29 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 19:18:33 +0100 Subject: [PATCH 04/11] feat(compare): DotStrip with England-average anchor Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../__tests__/components/DotStrip.test.tsx | 48 +++++++++ nextjs-app/components/DotStrip.module.css | 102 ++++++++++++++++++ nextjs-app/components/DotStrip.tsx | 97 +++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 nextjs-app/__tests__/components/DotStrip.test.tsx create mode 100644 nextjs-app/components/DotStrip.module.css create mode 100644 nextjs-app/components/DotStrip.tsx diff --git a/nextjs-app/__tests__/components/DotStrip.test.tsx b/nextjs-app/__tests__/components/DotStrip.test.tsx new file mode 100644 index 0000000..786f183 --- /dev/null +++ b/nextjs-app/__tests__/components/DotStrip.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; + +import { DotStrip } from '@/components/DotStrip'; + +describe('DotStrip', () => { + it('enumerates anchor and school values in the aria-label', () => { + render( + , + ); + const strip = screen.getByRole('img'); + expect(strip).toHaveAccessibleName( + 'Reading: England 75%, Barclay 91%, Elmhurst 92%, Plumcroft 87%', + ); + }); + + it('renders the anchor tick when provided and not otherwise', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('England 75%')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText(/England/)).not.toBeInTheDocument(); + }); + + it('skips schools without a value', () => { + render( + , + ); + expect(screen.getByRole('img')).toHaveAccessibleName('Maths: Barclay 91%'); + }); +}); diff --git a/nextjs-app/components/DotStrip.module.css b/nextjs-app/components/DotStrip.module.css new file mode 100644 index 0000000..2b7f380 --- /dev/null +++ b/nextjs-app/components/DotStrip.module.css @@ -0,0 +1,102 @@ +.row { + margin: 1.1rem 0 1.6rem; +} + +.head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + flex-wrap: wrap; +} + +.title { + font-weight: 600; + font-size: 0.95rem; +} + +.headNote { + font-size: 0.8rem; + color: var(--text-muted); +} + +.strip { + position: relative; + height: 34px; + margin-top: 0.45rem; +} + +.track { + position: absolute; + left: 0; + right: 0; + top: 15px; + height: 4px; + border-radius: 2px; + background: var(--bg-secondary); +} + +.anchorTick { + position: absolute; + top: 4px; + width: 2px; + height: 26px; + background: var(--text-muted); +} + +.anchorLabel { + position: absolute; + top: -14px; + transform: translateX(-50%); + font-size: 0.7rem; + color: var(--text-muted); + white-space: nowrap; +} + +.point { + position: absolute; + top: 9px; + width: 16px; + height: 16px; + border-radius: 50%; + transform: translateX(-50%); + border: 2px solid var(--bg-card); + box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08); +} + +.pointLabel { + position: absolute; + top: 27px; + transform: translateX(-50%); + font-size: 0.72rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.pointLabelAbove { + top: -6px; +} + +@media (max-width: 760px) { + .row { + margin: 0.9rem 0 1.3rem; + } + + .title { + font-size: 0.82rem; + } + + .strip { + height: 32px; + } + + .point { + width: 14px; + height: 14px; + } + + .pointLabel { + font-size: 0.64rem; + } +} diff --git a/nextjs-app/components/DotStrip.tsx b/nextjs-app/components/DotStrip.tsx new file mode 100644 index 0000000..cd3b843 --- /dev/null +++ b/nextjs-app/components/DotStrip.tsx @@ -0,0 +1,97 @@ +/** + * DotStrip — the compare screen's signature element: one measure per strip, + * every school's dot on a shared track, anchored by a grey England-average + * tick so "right of the tick = above average" needs no domain knowledge. + */ + +'use client'; + +import { stripPositions } from '@/lib/compareLogic'; +import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils'; +import styles from './DotStrip.module.css'; + +export interface DotStripProps { + label: string; + /** One value per school; index = the school's chart-colour index. */ + values: Array; + schoolNames: string[]; + /** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the + * benchmark isn't available — the caller should say why in `headNote`. */ + anchor?: { value: number; label: string } | null; + min?: number; + max?: number; + unit?: string; + /** Tooltip on the measure label (plain-English definition). */ + tip?: string; + /** Small note on the right of the header row (e.g. the tick legend). */ + headNote?: string; +} + +export function DotStrip({ + label, + values, + schoolNames, + anchor = null, + min = 0, + max = 100, + unit = '%', + tip, + headNote, +}: DotStripProps) { + const points = stripPositions(values, min, max); + const span = max - min; + const anchorPos = + anchor != null + ? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100)) + : null; + + const ariaParts = [ + anchor ? `${anchor.label}` : null, + ...points.map( + (p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`, + ), + ].filter(Boolean); + + return ( +
+
+ + {label} + + {headNote && {headNote}} +
+
+
+ {anchorPos != null && anchor && ( + <> + + + {anchor.label} + + + )} + {points.map((p) => ( + + + + {p.value} + + + ))} +
+
+ ); +} From 9f2260ce5056eec706a8e315294c51626a506565 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:49:17 +0100 Subject: [PATCH 05/11] feat(compare): at-a-glance, Ofsted, admissions and community sections Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../components/CompareOfsted.test.tsx | 98 ++++++++ .../components/compare/CompareAdmissions.tsx | 124 ++++++++++ .../components/compare/CompareAtAGlance.tsx | 180 +++++++++++++++ .../components/compare/CompareCommunity.tsx | 183 +++++++++++++++ .../components/compare/CompareOfsted.tsx | 217 ++++++++++++++++++ .../compare/compareSections.module.css | 216 +++++++++++++++++ .../components/compare/sectionShared.tsx | 97 ++++++++ nextjs-app/lib/compareLogic.ts | 4 +- 8 files changed, 1117 insertions(+), 2 deletions(-) create mode 100644 nextjs-app/__tests__/components/CompareOfsted.test.tsx create mode 100644 nextjs-app/components/compare/CompareAdmissions.tsx create mode 100644 nextjs-app/components/compare/CompareAtAGlance.tsx create mode 100644 nextjs-app/components/compare/CompareCommunity.tsx create mode 100644 nextjs-app/components/compare/CompareOfsted.tsx create mode 100644 nextjs-app/components/compare/compareSections.module.css create mode 100644 nextjs-app/components/compare/sectionShared.tsx diff --git a/nextjs-app/__tests__/components/CompareOfsted.test.tsx b/nextjs-app/__tests__/components/CompareOfsted.test.tsx new file mode 100644 index 0000000..8df5602 --- /dev/null +++ b/nextjs-app/__tests__/components/CompareOfsted.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; + +import { CompareOfsted } from '@/components/compare/CompareOfsted'; +import type { ComparisonData, OfstedInspection, School } from '@/lib/types'; + +function school(urn: number, name: string): School { + return { urn, school_name: name } as School; +} + +function ofsted(partial: Partial): OfstedInspection { + return { + framework: null, + inspection_date: '2021-10-07', + inspection_type: null, + overall_effectiveness: null, + quality_of_education: null, + behaviour_attitudes: null, + personal_development: null, + leadership_management: null, + early_years_provision: null, + previous_overall: null, + rc_safeguarding_met: null, + rc_inclusion: null, + rc_curriculum_teaching: null, + rc_achievement: null, + rc_attendance_behaviour: null, + rc_personal_development: null, + rc_leadership_governance: null, + rc_early_years: null, + rc_sixth_form: null, + ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1', + ...partial, + }; +} + +const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')]; + +const data: Record = { + '1': { + school_info: schools[0], + yearly_data: [], + ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }), + }, + '2': { + school_info: schools[1], + yearly_data: [], + ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }), + }, + '3': { + school_info: schools[2], + yearly_data: [], + ofsted: ofsted({ + inspection_date: '2025-11-14', + rc_safeguarding_met: true, + report_card: { + rc_achievement: { code: 2, label: 'Strong standard' }, + rc_attendance_behaviour: { code: 4, label: 'Needs attention' }, + }, + }), + }, +}; + +describe('CompareOfsted', () => { + it('renders the three regimes without inventing an overall grade for report cards', () => { + render(); + + expect(screen.getByText('Outstanding')).toBeInTheDocument(); + // Carried-forward grade is shown but marked as such + expect(screen.getByText('Good')).toBeInTheDocument(); + expect(screen.getByText(/carried forward/i)).toBeInTheDocument(); + // Report card: label present, no overall-grade badge for that school + expect(screen.getByText('Report card')).toBeInTheDocument(); + expect(screen.getByText(/no overall grade/i)).toBeInTheDocument(); + }); + + it('uses one chip-list grammar for both regimes in judgement detail', () => { + render(); + // report-card area chip + expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument(); + expect(screen.getByText('Needs attention')).toBeInTheDocument(); + // graded school without published subgrades → honest dataset statement + expect( + screen.getAllByText(/We don't hold area-by-area detail/i).length, + ).toBeGreaterThanOrEqual(1); + }); + + it('shows the mixed-regime comparability note only when regimes differ', () => { + render(); + expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument(); + }); + + it('links every school to its Ofsted page', () => { + render(); + const links = screen.getAllByRole('link', { name: /Ofsted page/i }); + expect(links).toHaveLength(3); + expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1'); + }); +}); diff --git a/nextjs-app/components/compare/CompareAdmissions.tsx b/nextjs-app/components/compare/CompareAdmissions.tsx new file mode 100644 index 0000000..aeaa3dc --- /dev/null +++ b/nextjs-app/components/compare/CompareAdmissions.tsx @@ -0,0 +1,124 @@ +/** + * Getting a place — admissions framed the way the expert review requires: + * total applications are "named on N forms" (any preference rank, not + * head-to-head), one consistent chip metric (first-preference success), + * equal-preference and offers-vs-intake explanations up front. + */ + +'use client'; + +import { summariseAdmissions } from '@/lib/compareLogic'; +import type { ComparisonData, School } from '@/lib/types'; +import { CHART_COLORS } from '@/lib/utils'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; + +export function CompareAdmissions({ + schools, + data, +}: { + schools: School[]; + data: Record; +}) { + const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null); + const anyData = rows.some(Boolean); + const entryYear = rows.find(Boolean)?.year; + const entryLabel = entryYear + ? `September ${String(entryYear).slice(0, 4)} entry` + : 'the most recent admissions round'; + + if (!anyData) { + return ( +
+ <> +
+ ); + } + + return ( +
+ From the most recent admissions round ({entryLabel}). "First choice" means + families who ranked the school top of their application form — officially a "first + preference". Schools never see your ranking: places are decided only by the + school's admission criteria, so listing a school lower down never hurts your chances. + These are National Offer Day offers — waiting lists and appeals can change the final + intake. + + } + > + + + Interest in the school + + {schools.map((school, i) => { + const a = rows[i]; + return ( + + {a?.total_applications != null && a?.places_offered != null ? ( + <> + Named on {a.total_applications.toLocaleString('en-GB')} forms ·{' '} + {a.places_offered.toLocaleString('en-GB')} places + + ) : ( + No data + )} + + ); + })} + + First-choice families offered a place + {schools.map((school, i) => { + const summary = summariseAdmissions(rows[i]); + return ( + + {summary.firstPrefPct != null ? ( + <> + {summary.firstPrefPct}%{' '} + {summary.chip && summary.chip.tone === 'warn' && ( + {summary.chip.text} + )} + + + + + ) : ( + No data + )} + + ); + })} + + What this means + {schools.map((school, i) => { + const a = rows[i]; + const summary = summariseAdmissions(a); + let text: string | null = null; + if (summary.firstPrefPct != null) { + if (summary.firstPrefPct >= 100) { + text = `Every family who put ${school.school_name} first got a place.`; + } else if (summary.firstPrefPct >= 90) { + text = `Nearly every family who put ${school.school_name} first got a place.`; + } else if (a?.oversubscribed) { + text = + 'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).'; + } else { + text = `${summary.firstPrefPct}% of first-choice families received an offer.`; + } + } + return ( + + {text ? {text} : } + + ); + })} + +
+ ); +} diff --git a/nextjs-app/components/compare/CompareAtAGlance.tsx b/nextjs-app/components/compare/CompareAtAGlance.tsx new file mode 100644 index 0000000..a2f6605 --- /dev/null +++ b/nextjs-app/components/compare/CompareAtAGlance.tsx @@ -0,0 +1,180 @@ +/** + * At a glance — the short version of every section below it. Copy verbatim + * from the reviewed mockups. Report-card cells summarise by counting graded + * areas (best first) and always NAME problem areas; safeguarding is a + * separate line, never a count. + */ + +'use client'; + +import { + latestValues, + ofstedDisplay, + summariseAdmissions, + verdict, + type ReportCardSummary, +} from '@/lib/compareLogic'; +import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; + +function ReportCardChips({ summary }: { summary: ReportCardSummary }) { + return ( + <> + Report card + + {summary.counts.map((c) => ( + + {c.count} area{c.count === 1 ? '' : 's'} {c.label} + + ))} + {summary.problems.map((p) => ( + + {p.areaLabel}: {p.label} + + ))} + + + {summary.allClear && 'No areas need attention · '} + {summary.safeguarding === 'met' && 'Safeguarding met'} + {summary.safeguarding === 'not_met' && 'Safeguarding not met'} + + + ); +} + +export function CompareAtAGlance({ + schools, + data, + nationalAverages, + benchmarks, +}: { + schools: School[]; + data: Record; + nationalAverages?: NationalAverages; + benchmarks?: Benchmarks; +}) { + const urns = schools.map((school) => school.urn); + const isSecondary = schools.some( + (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, + ); + const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct'; + const headlineValues = latestValues(data, urns, headlineKey); + const anchor = isSecondary + ? nationalAverages?.secondary?.attainment_8_score + : nationalAverages?.primary?.rwm_expected_pct; + const medianPupils = isSecondary + ? benchmarks?.secondary?.median_pupils + : benchmarks?.primary?.median_pupils; + + return ( +
+ + Latest Ofsted inspection + {schools.map((school, i) => { + const display = ofstedDisplay(data[String(school.urn)]?.ofsted); + return ( + + {display.kind === 'report_card' && } + {(display.kind === 'graded' || display.kind === 'carried_forward') && ( + <> + + {display.gradeLabel} + + {display.carriedForward && Grade carried forward} + + )} + {display.kind === 'none' && No inspection in our dataset} + + ); + })} + + + {isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'} + + {schools.map((school, i) => { + const value = headlineValues[i]; + return ( + + {value != null ? ( + <> + {isSecondary ? value.toFixed(1) : `${Math.round(value)}%`}{' '} + {anchor != null && ( + + {verdict(value, anchor) === 'above' && 'Above England average'} + {verdict(value, anchor) === 'close' && 'Close to England average'} + {verdict(value, anchor) === 'below' && 'Below England average'} + + )} + {anchor != null && ( + + England average {isSecondary ? anchor.toFixed(1) : `${Math.round(anchor)}%`} + + )} + + ) : ( + No data + )} + + ); + })} + + Getting a place + {schools.map((school, i) => { + const summary = summariseAdmissions(data[String(school.urn)]?.admissions); + return ( + + {summary.chip ? ( + <> + {summary.chip.text} + {summary.interest && {summary.interest}} + + ) : ( + No admissions data + )} + + ); + })} + + Size + {schools.map((school, i) => { + const census = data[String(school.urn)]?.census; + const pupils = census?.total_pupils ?? school.total_pupils ?? null; + let sizeNote: string | null = null; + if (pupils != null && medianPupils != null) { + if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average'; + else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average'; + else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average'; + else if (pupils <= medianPupils * 0.9) sizeNote = 'Smaller than average'; + else sizeNote = 'About average size'; + } + return ( + + {pupils != null ? ( + <> + {pupils.toLocaleString('en-GB')} pupils + {sizeNote && {sizeNote}} + + ) : ( + No data + )} + + ); + })} + +
+ ); +} diff --git a/nextjs-app/components/compare/CompareCommunity.tsx b/nextjs-app/components/compare/CompareCommunity.tsx new file mode 100644 index 0000000..9ae9cc3 --- /dev/null +++ b/nextjs-app/components/compare/CompareCommunity.tsx @@ -0,0 +1,183 @@ +/** + * Who goes there — the school's community from the latest census plus GIAS + * facts. Benchmark chips use the computed state-school averages and must + * carry their provenance wording (never "England average" for computed + * figures). Copy verbatim from the reviewed mockups. + */ + +'use client'; + +import { verdict } from '@/lib/compareLogic'; +import type { Benchmarks, ComparisonData, School } from '@/lib/types'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; + +function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null { + if (part == null || total == null || total === 0) return null; + return `${Math.round((part / total) * 100)}%`; +} + +export function CompareCommunity({ + schools, + data, + benchmarks, +}: { + schools: School[]; + data: Record; + benchmarks?: Benchmarks; +}) { + const isSecondary = schools.some( + (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, + ); + const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary; + + const fsmChip = (value: number | null) => { + if (value == null || bench?.disadvantaged_pct == null) return null; + const v = verdict(value, bench.disadvantaged_pct, 3); + return ( + + {v === 'above' && 'Above the state-school average'} + {v === 'close' && 'About the state-school average'} + {v === 'below' && 'Below the state-school average'} + + ); + }; + + return ( +
+ + Pupils on roll + {schools.map((school, i) => { + const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined; + const census = data[String(school.urn)]?.census; + const pupils = census?.total_pupils ?? info?.gias_total_pupils ?? null; + const capacity = info?.capacity ?? null; + let capNote: string | null = null; + if (pupils != null && capacity != null && capacity > 0) { + capNote = + pupils >= capacity + ? `${capacity.toLocaleString('en-GB')} places — at or above capacity` + : `of ${capacity.toLocaleString('en-GB')} places (${Math.round((pupils / capacity) * 100)}% full)`; + } + return ( + + {pupils != null ? ( + <> + {pupils.toLocaleString('en-GB')} + {capNote && {capNote}} + + ) : ( + No data + )} + + ); + })} + + Girls / boys + {schools.map((school, i) => { + const census = data[String(school.urn)]?.census; + const girls = pctSplit(census?.female_pupils, census?.total_pupils); + const boys = pctSplit(census?.male_pupils, census?.total_pupils); + return ( + + {girls && boys ? `${girls} / ${boys}` : No data} + + ); + })} + + + Free school meals + + {schools.map((school, i) => { + const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null; + return ( + + {fsm != null ? ( + <> + {Math.round(fsm)}% {fsmChip(fsm)} + + ) : ( + No data + )} + + ); + })} + + + English as an additional language + + {schools.map((school, i) => { + const eal = data[String(school.urn)]?.census?.eal_pct ?? null; + return ( + + {eal != null ? `${Math.round(eal)}%` : No data} + + ); + })} + + + Extra learning support (SEN) + + {schools.map((school, i) => { + const rows = data[String(school.urn)]?.yearly_data ?? []; + let sen: number | null = null; + for (let r = rows.length - 1; r >= 0; r--) { + if (rows[r].sen_support_pct != null) { + sen = rows[r].sen_support_pct; + break; + } + } + const high = + sen != null && bench?.sen_support_pct != null && sen >= bench.sen_support_pct * 1.75; + return ( + + {sen != null ? ( + <> + {Math.round(sen)}% {high && Well above average} + + ) : ( + No data + )} + + ); + })} + + Faith character + {schools.map((school, i) => { + const info = data[String(school.urn)]?.school_info; + const faith = info?.religious_denomination; + const none = !faith || faith === 'Does not apply' || faith === 'None'; + return ( + + {none ? 'None' : faith} + + ); + })} + + Ages + {schools.map((school, i) => { + const info = data[String(school.urn)]?.school_info; + return ( + + {info?.age_range || No data} + + ); + })} + + Run by + {schools.map((school, i) => { + const info = data[String(school.urn)]?.school_info; + const trust = info?.trust_name; + const la = info?.local_authority ?? school.local_authority; + return ( + + {trust ? trust : la ? `${la} council` : No data} + + ); + })} + +
+ ); +} diff --git a/nextjs-app/components/compare/CompareOfsted.tsx b/nextjs-app/components/compare/CompareOfsted.tsx new file mode 100644 index 0000000..e148674 --- /dev/null +++ b/nextjs-app/components/compare/CompareOfsted.tsx @@ -0,0 +1,217 @@ +/** + * Ofsted section — one visual grammar for inspection detail across all + * three regimes (legacy graded, interim carried-forward, renewed-framework + * report card). Copy comes verbatim from the reviewed mockups. + */ + +'use client'; + +import { + OFSTED_LEGACY_GRADES, + ofstedDisplay, + rcAreaLabel, + type OfstedDisplay, +} from '@/lib/compareLogic'; +import type { ComparisonData, OfstedInspection, School } from '@/lib/types'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; + +const GRADE_TONE: Record = { + 1: 'good', + 2: 'good', + 3: 'warn', + 4: 'bad', +}; + +const RC_CODE_TONE = (code: number): 'good' | 'warn' | 'bad' | 'neutral' => + code <= 2 ? 'good' : code === 3 ? 'neutral' : code === 4 ? 'warn' : 'bad'; + +function formatInspectionDate(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function yearsSince(iso: string | null): number | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000); +} + +function ResultCell({ display }: { display: OfstedDisplay }) { + if (display.kind === 'none') { + return No inspection outcome in our dataset; + } + if (display.kind === 'report_card') { + return ( + <> + Report card + New-style inspection — no overall grade is given + + ); + } + return ( + <> + + {display.gradeLabel} + + + {display.carriedForward + ? 'Grade carried forward from an earlier inspection (ungraded visit since)' + : 'Overall grade (older-style inspection)'} + + + ); +} + +function JudgementDetailCell({ + ofsted, + display, + schoolName, +}: { + ofsted: OfstedInspection; + display: OfstedDisplay; + schoolName: string; +}) { + if (display.kind === 'report_card') { + const entries = Object.entries(ofsted.report_card ?? {}); + return ( +
+ {entries.map(([key, entry]) => ( +
+ {rcAreaLabel(key)} + {entry.label} +
+ ))} + {ofsted.rc_safeguarding_met != null && ( +
+ Safeguarding + + {ofsted.rc_safeguarding_met ? 'Met' : 'Not met'} + +
+ )} +
+ ); + } + + const legacyAreas: Array<[string, number | null]> = [ + ['Quality of education', ofsted.quality_of_education], + ['Behaviour & attitudes', ofsted.behaviour_attitudes], + ['Personal development', ofsted.personal_development], + ['Leadership & management', ofsted.leadership_management], + ['Early years provision', ofsted.early_years_provision], + ]; + const published = legacyAreas.filter(([, grade]) => grade != null); + + if (published.length === 0) { + return ( + + We don't hold area-by-area detail for this inspection — see {schoolName}'s + Ofsted page for the full report. + + ); + } + return ( +
+ {published.map(([label, grade]) => ( +
+ {label} + + {OFSTED_LEGACY_GRADES[grade as number] ?? String(grade)} + +
+ ))} +
+ ); +} + +export function CompareOfsted({ + schools, + data, +}: { + schools: School[]; + data: Record; +}) { + const displays = schools.map((school) => ofstedDisplay(data[String(school.urn)]?.ofsted)); + const kinds = new Set(displays.map((d) => d.kind).filter((k) => k !== 'none')); + const mixedRegimes = kinds.size > 1; + + return ( +
+ Ofsted is the schools inspectorate. It stopped giving a single overall grade in{' '} + September 2024; inspections between then and November 2025 kept the + area-by-area judgements without an overall grade, and from November 2025{' '} + new inspections produce a report card rating each area of school life on + a five-point scale. + {mixedRegimes && ( + <> A report card and an older overall grade aren't directly comparable. + )}{' '} + (Ofsted's "Expected standard" rating is unrelated to the KS2 "expected + standard" test measure further down this page.) + + } + > + + Result + {schools.map((school, i) => ( + + + + ))} + + Inspected + {schools.map((school, i) => { + const ofsted = data[String(school.urn)]?.ofsted; + const age = yearsSince(ofsted?.inspection_date ?? null); + return ( + + {formatInspectionDate(ofsted?.inspection_date ?? null)}{' '} + {age != null && age > 4 && 4+ years ago} + + ); + })} + + + Judgement detail + + {schools.map((school, i) => { + const ofsted = data[String(school.urn)]?.ofsted; + return ( + + {ofsted ? ( + + ) : ( + No inspection in our dataset + )} + + ); + })} + + + Ofsted page + + {schools.map((school, i) => { + const url = + data[String(school.urn)]?.ofsted?.ofsted_page_url ?? + `https://reports.ofsted.gov.uk/provider/21/${school.urn}`; + return ( + + + {school.school_name}'s Ofsted page → + + + ); + })} + +
+ ); +} diff --git a/nextjs-app/components/compare/compareSections.module.css b/nextjs-app/components/compare/compareSections.module.css new file mode 100644 index 0000000..895adae --- /dev/null +++ b/nextjs-app/components/compare/compareSections.module.css @@ -0,0 +1,216 @@ +/* Shared layout for the compare screen's measure-first sections. + Mobile base: each row-label becomes a measure header and each school cell + stacks under it (colour-coded via the cell's ::before school tag). + Desktop (≥761px): the mockups' grid — 200px row-label column + one column + per school (2–4 columns supported via --school-count). */ + +.section { + margin-top: 3rem; +} + +.sectionTitle { + font-family: var(--font-playfair), 'Playfair Display', Georgia, serif; + font-size: 1.45rem; + font-weight: 700; + margin: 0; + padding-left: 0.75rem; + border-left: 3px solid var(--accent-coral-dark); +} + +.how { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0.35rem 0 0 0.95rem; + max-width: 70ch; +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; + margin-top: 1.25rem; +} + +.rowLabel { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.35rem; + background: var(--bg-secondary); + border-radius: 6px; + padding: 0.4rem 0.6rem; + margin-top: 0.8rem; +} + +.cell { + padding: 0.4rem 0.6rem; + font-size: 0.95rem; +} + +.cell::before { + content: attr(data-school); + display: block; + font-size: 0.72rem; + font-weight: 600; + color: var(--sc, var(--text-muted)); +} + +.big { + font-size: 1.35rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.small { + display: block; + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.chip { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + border-radius: 999px; + padding: 0.15rem 0.6rem; + white-space: nowrap; +} + +.chipGood { + background: rgba(45, 125, 125, 0.14); + color: var(--accent-teal); +} + +.chipWarn { + background: var(--accent-gold-bg); + color: var(--accent-gold-text); +} + +.chipBad { + background: var(--accent-coral-bg); + color: var(--accent-coral-dark); +} + +.chipNeutral { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.help { + display: inline-flex; + width: 15px; + height: 15px; + border-radius: 50%; + border: 1px solid var(--text-muted); + color: var(--text-muted); + font-size: 0.65rem; + align-items: center; + justify-content: center; + cursor: help; + flex: none; +} + +.badge { + display: inline-block; + font-weight: 700; + border-radius: 6px; + padding: 0.25rem 0.7rem; + font-size: 0.9rem; +} + +.badgeGood { + background: rgba(45, 125, 125, 0.14); + color: var(--accent-teal); +} + +.badgeWarn { + background: var(--accent-gold-bg); + color: var(--accent-gold-text); +} + +.badgeBad { + background: var(--accent-coral-bg); + color: var(--accent-coral-dark); +} + +.rcList { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 0.2rem; +} + +.rcRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.rcArea { + color: var(--text-secondary); +} + +.chipStack { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + margin-top: 0.3rem; +} + +.barMini { + display: block; + height: 8px; + border-radius: 4px; + background: var(--bg-secondary); + overflow: hidden; + margin-top: 0.3rem; + max-width: 140px; +} + +.barMini > i { + display: block; + height: 100%; + border-radius: 4px; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 16px; + box-shadow: var(--shadow-soft); + padding: 1.25rem 1.5rem; + margin-top: 1rem; +} + +.link { + color: var(--accent-coral-dark); +} + +@media (min-width: 761px) { + .grid { + grid-template-columns: 200px repeat(var(--school-count, 3), 1fr); + gap: 0 0.75rem; + } + + .rowLabel { + background: none; + border-radius: 0; + margin-top: 0; + padding: 0.85rem 0.5rem 0.85rem 0; + border-bottom: 1px solid var(--border-light); + } + + .cell { + padding: 0.85rem 0.25rem; + border-bottom: 1px solid var(--border-light); + } + + .cell::before { + content: none; + } +} diff --git a/nextjs-app/components/compare/sectionShared.tsx b/nextjs-app/components/compare/sectionShared.tsx new file mode 100644 index 0000000..89b5d7a --- /dev/null +++ b/nextjs-app/components/compare/sectionShared.tsx @@ -0,0 +1,97 @@ +/** + * Small shared pieces for the compare sections: the section shell, the + * row-label + per-school-cell grid, and tone-mapped chips. Copy passed into + * these comes verbatim from the reviewed mockups + * (docs/superpowers/specs/mockups/) — do not paraphrase it here. + */ + +'use client'; + +import type { CSSProperties, ReactNode } from 'react'; + +import type { School } from '@/lib/types'; +import { CHART_TEXT_COLORS } from '@/lib/utils'; +import styles from './compareSections.module.css'; + +export function Section({ + title, + how, + children, +}: { + title: string; + how?: ReactNode; + children: ReactNode; +}) { + return ( +
+

{title}

+ {how &&

{how}

} + {children} +
+ ); +} + +export function SectionGrid({ + schools, + children, +}: { + schools: School[]; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) { + return ( +
+ {children} + {tip && ( + + ? + + )} +
+ ); +} + +export function Cell({ + school, + index, + children, +}: { + school: School; + index: number; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral'; + +const CHIP_TONE_CLASS: Record = { + good: styles.chipGood, + warn: styles.chipWarn, + bad: styles.chipBad, + neutral: styles.chipNeutral, +}; + +export function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }) { + return {children}; +} + +export const sectionStyles = styles; diff --git a/nextjs-app/lib/compareLogic.ts b/nextjs-app/lib/compareLogic.ts index 3d4134f..0916161 100644 --- a/nextjs-app/lib/compareLogic.ts +++ b/nextjs-app/lib/compareLogic.ts @@ -229,14 +229,14 @@ export function stripPositions( /** Latest non-null yearly value of `metricKey` per school, in `urns` order. */ export function latestValues( - data: Record & { year: number }> }>, + data: Record }>, urns: number[], metricKey: string, ): Array { return urns.map((urn) => { const rows = data[String(urn)]?.yearly_data ?? []; for (let i = rows.length - 1; i >= 0; i--) { - const v = rows[i][metricKey]; + const v = (rows[i] as Record)[metricKey]; if (typeof v === 'number' && !Number.isNaN(v)) return v; } return null; From 2573cd2490e2361bdda01bec3de3c0f4d505fd03 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:50:29 +0100 Subject: [PATCH 06/11] feat(compare): academics strips with England anchors and More measures Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/__tests__/lib/compareLogic.test.ts | 28 ++ .../compare/CompareAcademics.module.css | 18 ++ .../components/compare/CompareAcademics.tsx | 292 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 nextjs-app/components/compare/CompareAcademics.module.css create mode 100644 nextjs-app/components/compare/CompareAcademics.tsx diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts index d181a5d..03c2432 100644 --- a/nextjs-app/__tests__/lib/compareLogic.test.ts +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -229,3 +229,31 @@ describe('stripPositions', () => { expect(pts[0].pos).toBe(0); }); }); + +describe('latestValues', () => { + const data = { + '1': { + yearly_data: [ + { year: 202324, rwm_expected_pct: 75 }, + { year: 202425, rwm_expected_pct: 87 }, + ], + }, + '2': { + yearly_data: [ + { year: 202324, rwm_expected_pct: 82 }, + { year: 202425, rwm_expected_pct: null }, + ], + }, + }; + + it('takes the latest non-null value per school in urn order', async () => { + const { latestValues } = await import('@/lib/compareLogic'); + expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]); + }); + + it('returns null for unknown schools and metrics', async () => { + const { latestValues } = await import('@/lib/compareLogic'); + expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]); + expect(latestValues(data, [1], 'nope')).toEqual([null]); + }); +}); diff --git a/nextjs-app/components/compare/CompareAcademics.module.css b/nextjs-app/components/compare/CompareAcademics.module.css new file mode 100644 index 0000000..42b36ef --- /dev/null +++ b/nextjs-app/components/compare/CompareAcademics.module.css @@ -0,0 +1,18 @@ +.moreMeasures { + margin-top: 0.5rem; + border-top: 1px solid var(--border-light); + padding-top: 0.75rem; +} + +.moreMeasures summary { + cursor: pointer; + font-weight: 600; + font-size: 0.88rem; + color: var(--accent-coral-dark); +} + +.stripNote { + font-size: 0.78rem; + color: var(--text-muted); + margin: 0.5rem 0 0; +} diff --git a/nextjs-app/components/compare/CompareAcademics.tsx b/nextjs-app/components/compare/CompareAcademics.tsx new file mode 100644 index 0000000..d31c348 --- /dev/null +++ b/nextjs-app/components/compare/CompareAcademics.tsx @@ -0,0 +1,292 @@ +/** + * How children do academically — tier-1 dot strips anchored on official + * England averages, tier-2 "More measures" one tap away, equity row against + * the computed state-school benchmark. Copy verbatim from the reviewed + * mockups; teacher-assessed measures are labelled as such. + */ + +'use client'; + +import { latestValues, verdict } from '@/lib/compareLogic'; +import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; +import { DotStrip } from '@/components/DotStrip'; +import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import styles from './CompareAcademics.module.css'; + +interface StripSpec { + label: string; + metric: string; + anchorKey?: string; + tip?: string; + min?: number; + max?: number; + unit?: string; +} + +const TIER1_PRIMARY: StripSpec[] = [ + { + label: 'Reading, writing & maths — expected standard', + metric: 'rwm_expected_pct', + anchorKey: 'rwm_expected_pct', + tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.', + }, + { label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' }, + { + label: 'Writing (teacher-assessed)', + metric: 'writing_expected_pct', + anchorKey: 'writing_expected_pct', + tip: 'Writing is assessed by teachers, not tested.', + }, + { label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' }, + { + label: 'Working at a higher standard than expected', + metric: 'rwm_high_pct', + anchorKey: 'rwm_high_pct', + tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.', + }, +]; + +const TIER2_PRIMARY: StripSpec[] = [ + { + label: 'Grammar, punctuation & spelling — expected standard', + metric: 'gps_expected_pct', + anchorKey: 'gps_expected_pct', + }, + { + label: 'Science — expected standard (teacher-assessed)', + metric: 'science_expected_pct', + anchorKey: 'science_expected_pct', + tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.', + }, + { + label: 'Average scaled score — reading', + metric: 'reading_avg_score', + anchorKey: 'reading_avg_score', + min: 100, + max: 120, + unit: '', + }, + { + label: 'Average scaled score — maths', + metric: 'maths_avg_score', + anchorKey: 'maths_avg_score', + min: 100, + max: 120, + unit: '', + }, + { + label: 'Average scaled score — grammar, punctuation & spelling', + metric: 'gps_avg_score', + anchorKey: 'gps_avg_score', + min: 100, + max: 120, + unit: '', + }, +]; + +function Strip({ + spec, + data, + urns, + schoolNames, + national, +}: { + spec: StripSpec; + data: Record; + urns: number[]; + schoolNames: string[]; + national: Record | undefined; +}) { + const values = latestValues(data, urns, spec.metric).map((v) => + v != null ? Math.round(v) : null, + ); + const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined; + const anchor = + anchorValue != null + ? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` } + : null; + if (values.every((v) => v == null)) return null; + return ( + + ); +} + +export function CompareAcademics({ + schools, + data, + nationalAverages, + benchmarks, +}: { + schools: School[]; + data: Record; + nationalAverages?: NationalAverages; + benchmarks?: Benchmarks; +}) { + const urns = schools.map((school) => school.urn); + const schoolNames = schools.map((school) => school.school_name); + const isSecondary = schools.some( + (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, + ); + + if (isSecondary) { + const att8 = latestValues(data, urns, 'attainment_8_score'); + const banding = urns.map((urn) => { + const rows = data[String(urn)]?.yearly_data ?? []; + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string; + } + return null; + }); + const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct'); + const ebacc = latestValues(data, urns, 'ebacc_entry_pct'); + const att8Anchor = nationalAverages?.secondary?.attainment_8_score; + + return ( +
+ + Attainment 8 + {schools.map((school, i) => ( + + {att8[i] != null ? ( + <> + {(att8[i] as number).toFixed(1)} + {att8Anchor != null && ( + England average {att8Anchor.toFixed(1)} + )} + + ) : ( + No data + )} + + ))} + + Progress 8 + {schools.map((school, i) => ( + + {banding[i] ? ( + + {banding[i]} + + ) : ( + No data + )} + + ))} + + + Grade 5+ in English & maths + + {schools.map((school, i) => ( + + {grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : No data} + + ))} + + EBacc entry + {schools.map((school, i) => ( + + {ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : No data} + + ))} + +
+ ); + } + + const national = nationalAverages?.primary; + const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct'); + const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; + + return ( +
+
+ {TIER1_PRIMARY.map((spec) => ( + + ))} + +
+ More measures — grammar, punctuation & spelling, science, average scaled scores + {TIER2_PRIMARY.map((spec) => ( + + ))} +

+ The scaled-score strips show the 100–120 window of the full 80–120 range; 100 is the + expected standard. Where an England tick is missing, the official figure isn't in + our dataset yet. +

+
+
+ + {disadvantaged.some((v) => v != null) && ( + + + Children from lower-income families + + {schools.map((school, i) => { + const value = disadvantaged[i]; + return ( + + {value != null ? ( + <> + + {Math.round(value)}% + {' '} + {disadvantagedAnchor != null && ( + + {verdict(value, disadvantagedAnchor, 5) === 'above' && + `Well above the ${Math.round(disadvantagedAnchor)}% state-school average`} + {verdict(value, disadvantagedAnchor, 5) === 'close' && + `Around the ${Math.round(disadvantagedAnchor)}% state-school average`} + {verdict(value, disadvantagedAnchor, 5) === 'below' && + `Below the ${Math.round(disadvantagedAnchor)}% state-school average`} + + )} + + ) : ( + No data + )} + + ); + })} + + )} +
+ ); +} From 519584f34ba8d56d0bad9566e3799e1b26b3de88 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:54:20 +0100 Subject: [PATCH 07/11] feat(compare): trends explorer with England line; gap-honest axis; series regression guard Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../__tests__/lib/compareChartData.test.ts | 84 ++++++++ .../components/ComparisonChart.module.css | 6 + nextjs-app/components/ComparisonChart.tsx | 56 +++-- .../compare/TrendsExplorer.module.css | 96 +++++++++ .../components/compare/TrendsExplorer.tsx | 195 ++++++++++++++++++ nextjs-app/lib/compareChartData.ts | 102 +++++++++ 6 files changed, 523 insertions(+), 16 deletions(-) create mode 100644 nextjs-app/__tests__/lib/compareChartData.test.ts create mode 100644 nextjs-app/components/compare/TrendsExplorer.module.css create mode 100644 nextjs-app/components/compare/TrendsExplorer.tsx create mode 100644 nextjs-app/lib/compareChartData.ts diff --git a/nextjs-app/__tests__/lib/compareChartData.test.ts b/nextjs-app/__tests__/lib/compareChartData.test.ts new file mode 100644 index 0000000..09325ae --- /dev/null +++ b/nextjs-app/__tests__/lib/compareChartData.test.ts @@ -0,0 +1,84 @@ +/** + * buildCompareChart: every selected school must produce a rendered series + * (regression guard for the production bug where a third school's line + * vanished), the x-axis must include cancelled/unpublished years as real + * gaps (never compressing time), and the England overlay renders dashed + * with no gap-bridging. + */ + +import { buildCompareChart, fillAcademicYears } from '@/lib/compareChartData'; +import type { ComparisonData } from '@/lib/types'; + +function school(urn: number, years: Array<[number, number | null]>): ComparisonData { + return { + school_info: { urn, school_name: `School ${urn}` } as ComparisonData['school_info'], + yearly_data: years.map(([year, v]) => ({ year, rwm_expected_pct: v })) as ComparisonData['yearly_data'], + }; +} + +const THREE_SCHOOLS = { + '1': school(1, [[201819, 87], [202223, 87], [202425, 87]]), + '2': school(2, [[201819, 88], [202223, 88], [202425, 92]]), + '3': school(3, [[201819, 69], [202223, 62], [202425, 79]]), +}; + +const SCHOOL_LIST = [1, 2, 3].map((urn) => ({ urn, school_name: `School ${urn}` })); + +describe('fillAcademicYears', () => { + it('fills every academic year between min and max', () => { + expect(fillAcademicYears([201819, 202223])).toEqual([ + 201819, 201920, 202021, 202122, 202223, + ]); + }); +}); + +describe('buildCompareChart', () => { + it('renders one series per selected school — none silently dropped', () => { + const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct'); + expect(chart.schoolDatasets).toHaveLength(3); + for (const ds of chart.schoolDatasets) { + expect(ds.data.some((v) => v != null)).toBe(true); + } + }); + + it('handles float years from the API (202425.0 style)', () => { + const floaty = { + '1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]), + }; + const chart = buildCompareChart(floaty, [SCHOOL_LIST[0]], 'rwm_expected_pct'); + expect(chart.schoolDatasets[0].data.filter((v) => v != null)).toHaveLength(2); + }); + + it('includes cancelled/unpublished years as null gaps, not compressed time', () => { + const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct'); + expect(chart.years).toContain(201920); + expect(chart.years).toContain(202122); + const idx = chart.years.indexOf(202021); + expect(chart.schoolDatasets[0].data[idx]).toBeNull(); + }); + + it('adds a dashed England overlay when national data is supplied', () => { + const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', { + 201819: 64.9, + 202122: 58.7, + 202223: 59.5, + 202425: 62.1, + }); + expect(chart.englandDataset).not.toBeNull(); + const eng = chart.englandDataset!; + expect(eng.label).toBe('England average'); + expect(eng.borderDash).toEqual([5, 4]); + expect(eng.spanGaps).toBe(false); + // England has a value for 2021/22 even though schools do not + expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7); + }); + + it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => { + const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', { + 202122: 58.7, + }); + expect(withNational.showUnpublished202122Note).toBe(true); + const withoutNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct'); + expect(withoutNational.showUnpublished202122Note).toBe(false); + }); +}); diff --git a/nextjs-app/components/ComparisonChart.module.css b/nextjs-app/components/ComparisonChart.module.css index dbd82c6..d847f1d 100644 --- a/nextjs-app/components/ComparisonChart.module.css +++ b/nextjs-app/components/ComparisonChart.module.css @@ -65,3 +65,9 @@ min-width: 0; } } + +.chartNote { + font-size: 0.78rem; + color: var(--text-muted); + margin: 0.5rem 0 0; +} diff --git a/nextjs-app/components/ComparisonChart.tsx b/nextjs-app/components/ComparisonChart.tsx index d922991..851b03d 100644 --- a/nextjs-app/components/ComparisonChart.tsx +++ b/nextjs-app/components/ComparisonChart.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { ChartOptions, ChartDataset, PointStyle } from 'chart.js'; import '@/lib/chartSetup'; +import { buildCompareChart } from '@/lib/compareChartData'; import type { ComparisonData } from '@/lib/types'; import { CHART_COLORS, @@ -34,13 +35,16 @@ interface ComparisonChartProps { schools: Array<{ urn: number; school_name: string }>; metric: string; metricLabel: string; + /** Official England figure per academic year for this metric — renders a + * dashed grey reference line when provided. */ + nationalByYear?: Record; } // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so // converging lines stay tellable apart without relying on hue alone. const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; -export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) { +export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) { const isMobile = useIsMobile(); const [focusedUrn, setFocusedUrn] = useState(null); @@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel } return
No data available
; } - // Union of years across all schools — coverage differs between them. - const years = [ - ...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])), - ].sort((a, b) => a - b); + // Pure, tested series construction: union of years with cancelled / + // unpublished years kept as real gaps, plus the England overlay. + const built = buildCompareChart(comparisonData, schools, metric, nationalByYear); + const { years } = built; - const datasets: ChartDataset<'line'>[] = schools.map((school, index) => { - const data = comparisonData[String(school.urn)]; - const color = CHART_COLORS[index % CHART_COLORS.length]; + const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => { + const school = schools[series.schoolIndex]; + const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length]; const dimmed = focusedUrn !== null && focusedUrn !== school.urn; return { - label: school.school_name, - data: years.map((year) => { - const yearData = data?.yearly_data.find((d) => d.year === year); - if (!yearData) return null; - return yearData[metric as keyof typeof yearData] as number | null; - }), + label: series.label, + data: series.data, borderColor: dimmed ? rgbToRgba(color, 0.2) : color, backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1), borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2, - pointStyle: POINT_STYLES[index % POINT_STYLES.length], + pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length], pointRadius: dimmed ? 2 : isMobile ? 3 : 4, pointHoverRadius: isMobile ? 5 : 6, tension: 0.3, - spanGaps: true, + // Never bridge missing years — gaps are information (COVID + // cancellations, unpublished 2021/22, schools that opened later). + spanGaps: false, }; }); + if (built.englandDataset) { + datasets.push({ + label: built.englandDataset.label, + data: built.englandDataset.data, + borderColor: 'rgba(109, 104, 95, 0.9)', + backgroundColor: 'transparent', + borderWidth: 1.5, + borderDash: built.englandDataset.borderDash, + pointStyle: 'line', + pointRadius: 0, + pointHoverRadius: 4, + tension: 0, + spanGaps: false, + }); + } + const chartData = { labels: years.map(formatAcademicYear), datasets, @@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
+ {built.showUnpublished202122Note && ( +

+ No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish + school-level figures for 2021/22 — the England average is shown for that year. +

+ )}
); } diff --git a/nextjs-app/components/compare/TrendsExplorer.module.css b/nextjs-app/components/compare/TrendsExplorer.module.css new file mode 100644 index 0000000..d6ddd4d --- /dev/null +++ b/nextjs-app/components/compare/TrendsExplorer.module.css @@ -0,0 +1,96 @@ +.explore { + margin-top: 1rem; +} + +.explore summary { + cursor: pointer; + font-weight: 600; + color: var(--accent-coral-dark); + padding: 0.85rem 1.1rem; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 8px; +} + +.explore[open] summary { + border-radius: 8px 8px 0 0; +} + +.inner { + border: 1px solid var(--border-light); + border-top: none; + border-radius: 0 0 8px 8px; + background: var(--bg-card); + padding: 1.25rem 1.5rem; +} + +.picker { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.picker label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); +} + +.picker select { + font-family: inherit; + font-size: 0.9rem; + padding: 0.4rem 0.6rem; + border-radius: 8px; + border: 1px solid var(--border-light); + background: var(--bg-card); + color: var(--text-primary); + max-width: 100%; +} + +.desc { + font-size: 0.78rem; + color: var(--text-muted); +} + +.progressNote { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0 0 1rem; +} + +.chartBox { + min-height: 320px; +} + +.tableWrapper { + overflow-x: auto; + margin-top: 1.5rem; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.table th, +.table td { + text-align: left; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border-light); +} + +.table th { + background: var(--bg-secondary); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-secondary); +} + +.yearCell { + font-weight: 600; + white-space: nowrap; +} diff --git a/nextjs-app/components/compare/TrendsExplorer.tsx b/nextjs-app/components/compare/TrendsExplorer.tsx new file mode 100644 index 0000000..c4ffd64 --- /dev/null +++ b/nextjs-app/components/compare/TrendsExplorer.tsx @@ -0,0 +1,195 @@ +/** + * Explore trends — the full grouped metric catalogue (nothing from the old + * compare page is lost; spec §4's tier 3) driving the year-by-year chart + * with its England reference line, plus the year-by-year table. Progress + * metrics carry CI-based bands for the years DfE published them. + */ + +'use client'; + +import { useState } from 'react'; +import dynamic from 'next/dynamic'; + +import { progressBand } from '@/lib/compareLogic'; +import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types'; +import { formatAcademicYear, formatMetricValue, metricKind } from '@/lib/utils'; +import { track } from '@/lib/analytics'; +import { Chip, Section, sectionStyles as s } from './sectionShared'; +import styles from './TrendsExplorer.module.css'; + +const ComparisonChart = dynamic( + () => import('../ComparisonChart').then((m) => m.ComparisonChart), + { ssr: false }, +); + +const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [ + { label: 'Expected Standard', category: 'expected' }, + { label: 'Higher Standard', category: 'higher' }, + { label: 'Progress Scores', category: 'progress' }, + { label: 'Average Scores', category: 'average' }, + { label: 'Gender Performance', category: 'gender' }, + { label: 'Equity (Disadvantaged)', category: 'equity' }, + { label: 'School Context', category: 'context' }, + { label: 'Absence', category: 'absence' }, + { label: '3-Year Trends', category: 'trends' }, +]; + +const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [ + { label: 'GCSE Performance', category: 'gcse' }, +]; + +export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category); +export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category); + +const PROGRESS_CI: Record = { + reading_progress: ['reading_progress_lower_ci', 'reading_progress_upper_ci'], + writing_progress: ['writing_progress_lower_ci', 'writing_progress_upper_ci'], + maths_progress: ['maths_progress_lower_ci', 'maths_progress_upper_ci'], +}; + +const BAND_LABEL = { above: 'Above average', average: 'Average', below: 'Below average' } as const; + +export function TrendsExplorer({ + schools, + data, + metrics, + initialMetric, + isPrimaryPhase, + nationalAverages, +}: { + schools: School[]; + data: Record; + metrics: MetricDefinition[]; + initialMetric: string; + isPrimaryPhase: boolean; + nationalAverages?: NationalAverages; +}) { + const [metric, setMetric] = useState(initialMetric); + + const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; + const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; + const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category)); + const metricDef = metrics.find((m) => m.key === metric); + const metricLabel = metricDef?.label || metric; + + const nationalByYear: Record = {}; + for (const entry of nationalAverages?.by_year ?? []) { + const block = isPrimaryPhase ? entry.primary : entry.secondary; + nationalByYear[entry.year] = block?.[metric] ?? null; + } + + const years = [ + ...new Set( + schools.flatMap( + (school) => data[String(school.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [], + ), + ), + ].sort((a, b) => a - b); + + const handleMetricChange = (next: string) => { + track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' }); + setMetric(next); + }; + + const ciKeys = PROGRESS_CI[metric]; + + return ( +
+
+ Year-by-year trends +
+
+ + + {metricDef?.description && {metricDef.description}} +
+ + {metric.includes('progress') && ( +

+ Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the + national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline); + bands use DfE's confidence intervals, not the raw score alone. +

+ )} + +
+ +
+ + {years.length > 0 && ( +
+ + + + + {schools.map((school) => ( + + ))} + + + + {years.map((year) => ( + + + {schools.map((school) => { + const row = data[String(school.urn)]?.yearly_data.find( + (d) => Math.trunc(d.year) === year, + ) as (Record & { year: number }) | undefined; + const value = row?.[metric]; + if (typeof value !== 'number') return ; + const band = ciKeys + ? progressBand( + value, + (row?.[ciKeys[0]] as number | null) ?? null, + (row?.[ciKeys[1]] as number | null) ?? null, + ) + : null; + return ( + + ); + })} + + ))} + +
Year{school.school_name}
{formatAcademicYear(year)} + {formatMetricValue(value, metricKind(metric))}{' '} + {band && ( + + {BAND_LABEL[band]} + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/nextjs-app/lib/compareChartData.ts b/nextjs-app/lib/compareChartData.ts new file mode 100644 index 0000000..728fd7a --- /dev/null +++ b/nextjs-app/lib/compareChartData.ts @@ -0,0 +1,102 @@ +/** + * Pure series-building for the comparison trend chart, extracted from + * ComparisonChart so it is unit-testable without a canvas. + * + * Chart truthfulness rules (spec §8.1): every academic year between the + * first and last data point appears on the axis — cancelled test years + * (2019/20, 2020/21) and the unpublished 2021/22 school-level year render + * as real gaps, never as compressed time; school lines never bridge gaps. + */ + +import type { ComparisonData } from './types'; + +/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */ +function nextAcademicYear(year: number): number { + const start = Math.floor(year / 100); + const end = year % 100; + return (start + 1) * 100 + (end + 1); +} + +/** Every academic year from min(years) to max(years), inclusive. */ +export function fillAcademicYears(years: number[]): number[] { + if (years.length === 0) return []; + const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b); + const out: number[] = []; + let y = ints[0]; + const last = ints[ints.length - 1]; + while (y <= last && out.length < 50) { + out.push(y); + y = nextAcademicYear(y); + } + return out; +} + +export interface CompareChartSeries { + label: string; + data: Array; + /** Index into CHART_COLORS / point styles. */ + schoolIndex: number; + spanGaps: false; +} + +export interface EnglandSeries { + label: 'England average'; + data: Array; + borderDash: [number, number]; + spanGaps: false; +} + +export interface CompareChart { + years: number[]; + schoolDatasets: CompareChartSeries[]; + englandDataset: EnglandSeries | null; + /** True when England published a 2021/22 figure but no school has one — + * the UI shows: "DfE didn't publish school-level figures for 2021/22". */ + showUnpublished202122Note: boolean; +} + +export function buildCompareChart( + comparisonData: Record, + schools: Array<{ urn: number; school_name: string }>, + metric: string, + nationalByYear?: Record, +): CompareChart { + const rawYears = schools.flatMap( + (s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [], + ); + const years = fillAcademicYears(rawYears); + + const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => { + const rows = comparisonData[String(school.urn)]?.yearly_data ?? []; + const byYear = new Map>(); + for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record); + return { + label: school.school_name, + data: years.map((year) => { + const v = byYear.get(year)?.[metric]; + return typeof v === 'number' && !Number.isNaN(v) ? v : null; + }), + schoolIndex, + spanGaps: false, + }; + }); + + let englandDataset: EnglandSeries | null = null; + if (nationalByYear) { + const data = years.map((year) => { + const v = nationalByYear[year]; + return typeof v === 'number' && !Number.isNaN(v) ? v : null; + }); + if (data.some((v) => v != null)) { + englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false }; + } + } + + const idx202122 = years.indexOf(202122); + const showUnpublished202122Note = + idx202122 >= 0 && + englandDataset?.data[idx202122] != null && + schoolDatasets.every((ds) => ds.data[idx202122] == null); + + return { years, schoolDatasets, englandDataset, showUnpublished202122Note }; +} From 2155256177e52021cd10a9ec44ef2dda52e93374 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:58:00 +0100 Subject: [PATCH 08/11] feat(compare): parent-first compare screen assembly Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/app/compare/page.tsx | 6 +- .../components/ComparisonView.module.css | 456 +++--------------- nextjs-app/components/ComparisonView.tsx | 429 ++++++---------- .../components/compare/TrendsExplorer.tsx | 12 +- 4 files changed, 240 insertions(+), 663 deletions(-) diff --git a/nextjs-app/app/compare/page.tsx b/nextjs-app/app/compare/page.tsx index 1a1ff38..31689a5 100644 --- a/nextjs-app/app/compare/page.tsx +++ b/nextjs-app/app/compare/page.tsx @@ -16,8 +16,10 @@ interface ComparePageProps { export const metadata: Metadata = { title: 'Compare Schools', - description: 'Compare KS2 performance across multiple primary schools in England', - keywords: 'school comparison, compare schools, KS2 comparison, primary school performance', + description: + 'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.', + keywords: + 'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance', }; // Dynamic via searchParams; remove force-dynamic so internal data fetches diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index b6f1af6..633ba12 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -28,8 +28,15 @@ color: var(--text-secondary, #5c564d); margin: 0; line-height: 1.6; + max-width: 60ch; } +.headerActions { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} /* Phase Tabs */ .phaseTabs { @@ -72,408 +79,87 @@ background: var(--accent-coral-darker, #9c3f26); } -/* Metric Selector */ -.metricSelector { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 2rem; +/* Sticky school bar — column identity while scrolling; horizontal scroll on + narrow screens */ +.schoolBar { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-primary, #faf7f2); display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); -} - -.metricLabel { - font-size: 0.9375rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - white-space: nowrap; -} - -.metricSelect { - flex: 1; - max-width: 400px; - padding: 0.625rem 1rem; - font-size: 0.9375rem; - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 8px; - background: var(--bg-card, white); - color: var(--text-primary, #1a1612); - cursor: pointer; - transition: all 0.2s ease; -} - -.metricSelect:hover { - border-color: var(--accent-coral, #e07256); -} - -.metricSelect:focus { - outline: none; - border-color: var(--accent-coral, #e07256); - box-shadow: 0 0 0 3px var(--accent-coral-bg); -} - -.metricSelect optgroup { - font-weight: 700; - color: var(--text-primary, #1a1612); - background: var(--bg-secondary, #f3ede4); - padding: 0.5rem 0; -} - -.metricSelect option { - font-weight: 400; - color: var(--text-secondary, #5c564d); - padding: 0.375rem 1rem; -} - -/* Schools Section */ -.schoolsSection { - margin-bottom: 2rem; -} - -.schoolsGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1.5rem; -} - -.schoolCard { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-left: 3px solid var(--accent-teal, #2d7d7d); - border-radius: 12px; - padding: 1.5rem; - position: relative; - box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); - transition: all 0.3s ease; - display: flex; - flex-direction: column; -} - -.schoolCard:hover { - box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1)); - transform: translateY(-2px); -} - -.removeButton { - position: absolute; - top: 0.75rem; - right: 0.75rem; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: var(--accent-coral, #e07256); - color: white; - border: none; - border-radius: 50%; - font-size: 1.25rem; - line-height: 1; - cursor: pointer; - transition: all 0.2s ease; -} - -.removeButton:hover { - background: var(--accent-coral-dark, #c45a3f); - transform: scale(1.1); -} - -.schoolName { - font-size: 1.125rem; - font-weight: 600; - margin-bottom: 0.75rem; - padding-right: 2rem; - line-height: 1.3; - font-family: var(--font-playfair), 'Playfair Display', serif; -} - -.schoolName a { - color: var(--text-primary, #1a1612); - text-decoration: none; - transition: color 0.2s ease; -} - -.schoolName a:hover { - color: var(--accent-coral-dark, #b04a2e); -} - -.schoolMeta { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; - flex: 1; -} - -.metaItem { - font-size: 0.875rem; - color: var(--text-secondary, #5c564d); - display: flex; - align-items: center; - gap: 0.25rem; -} - -.latestValue { - margin-top: auto; - padding-top: 1rem; - border-top: 1px solid var(--border-color, #e5dfd5); - text-align: center; - background: var(--bg-secondary, #f3ede4); - margin-left: -1.5rem; - margin-right: -1.5rem; - margin-bottom: -1.5rem; - padding: 1.25rem 1.5rem; - border-radius: 0 0 12px 9px; -} - -.latestLabel { - font-size: 0.75rem; - color: var(--text-muted, #8a847a); - margin-bottom: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.latestNumber { - font-size: 1.75rem; - font-weight: 700; - color: var(--accent-teal, #2d7d7d); -} - -/* Chart Section */ -.chartSection { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 12px; - padding: 2rem; - margin-bottom: 2rem; - box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); -} - -.sectionTitle { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin-bottom: 1.5rem; - padding-bottom: 0.75rem; - border-bottom: 2px solid var(--border-color, #e5dfd5); - font-family: var(--font-playfair), 'Playfair Display', serif; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.sectionTitle::before { - content: ''; - display: inline-block; - width: 4px; - height: 1em; - background: var(--accent-coral, #e07256); - border-radius: 2px; -} - -.chartContainer { - width: 100%; - height: 400px; - position: relative; -} - -.loadingMessage { - text-align: center; - padding: 3rem; - color: var(--text-secondary, #5c564d); - font-size: 1rem; -} - -/* Table Section */ -.tableSection { - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 12px; - padding: 2rem; - margin-bottom: 2rem; - box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); -} - -.tableWrapper { + gap: 0.75rem; overflow-x: auto; - max-width: 100%; - margin-top: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-light, #e5dfd5); -webkit-overflow-scrolling: touch; } -/* Right-edge fade so phone users see the comparison table scrolls. - Otherwise the wider-than-viewport table silently clips. */ -@media (max-width: 640px) { - .tableWrapper { - -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); - mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); - } +.schoolChip { + flex: 1 1 0; + min-width: 180px; + background: var(--bg-card, white); + border: 1px solid var(--border-light, #e5dfd5); + border-top: 3px solid var(--accent-coral, #e07256); + border-radius: 8px; + box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); + padding: 0.55rem 0.75rem; + display: flex; + gap: 0.55rem; + align-items: center; } -.comparisonTable { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: 0.9375rem; +.chipDot { + width: 11px; + height: 11px; + border-radius: 50%; + flex: none; } -.comparisonTable thead { - background: var(--bg-secondary, #f3ede4); +.chipText { + min-width: 0; } -.comparisonTable th { - padding: 1rem; - text-align: left; +.chipName { + display: block; font-weight: 600; + font-size: 0.92rem; + line-height: 1.25; color: var(--text-primary, #1a1612); - border-bottom: 2px solid var(--border-color, #e5dfd5); - background: var(--bg-secondary, #f3ede4); + text-decoration: none; +} + +.chipName:hover { + color: var(--accent-coral-dark, #b04a2e); +} + +.chipMeta { + display: block; + font-size: 0.78rem; + color: var(--text-muted, #6d685f); white-space: nowrap; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.05em; + overflow: hidden; + text-overflow: ellipsis; } -.comparisonTable td { - padding: 1rem; - border-bottom: 1px solid var(--border-color, #e5dfd5); - color: var(--text-secondary, #5c564d); - text-align: left; - background: var(--bg-card, white); -} - -/* Sticky first column (Year) so labels remain visible while scrolling */ -.comparisonTable th:first-child, -.comparisonTable td:first-child { - position: sticky; - left: 0; - z-index: 1; - box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08); -} - -.comparisonTable thead th:first-child { - z-index: 2; -} - -.comparisonTable tbody tr:hover td:first-child { +.chipRemove { + margin-left: auto; + border: none; background: var(--bg-secondary, #f3ede4); + color: var(--text-muted, #6d685f); + border-radius: 50%; + width: 22px; + height: 22px; + cursor: pointer; + flex: none; + font-size: 0.9rem; + line-height: 1; } -.comparisonTable tbody tr:last-child td { - border-bottom: none; -} - -.comparisonTable tbody tr:hover { - background: var(--bg-secondary, #f3ede4); -} - -.yearCell { - font-weight: 700; - color: var(--accent-gold, #c9a227); -} - -/* Empty State */ -.emptyState { - text-align: center; - padding: 4rem 2rem; - background: var(--bg-card, white); - border: 1px solid var(--border-color, #e5dfd5); - border-radius: 12px; -} - -.emptyStateTitle { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin-bottom: 0.5rem; - font-family: var(--font-playfair), 'Playfair Display', serif; -} - -.emptyStateDescription { - font-size: 1rem; - color: var(--text-secondary, #5c564d); - max-width: 400px; - margin: 0 auto 1.5rem; -} - -.metricDescription { - margin-top: 0.5rem; - font-size: 0.85rem; - color: var(--text-secondary); - max-width: 600px; - flex-basis: 100%; - margin-top: 0.25rem; -} - -.progressNote { - background: var(--bg-secondary); - border-left: 3px solid var(--accent-teal); - padding: 0.75rem 1rem; - margin: 0 0 1.5rem; - font-size: 0.875rem; - color: var(--text-secondary); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; -} - - -/* Responsive Design */ -@media (max-width: 768px) { - .headerContent { - flex-direction: column; - align-items: stretch; - } - - .header h1 { - font-size: 1.75rem; - } - - .metricSelector { - flex-direction: column; - align-items: stretch; - padding: 1rem; - border-radius: 8px; - } - - .metricSelect { - max-width: 100%; - } - - .schoolsGrid { - grid-template-columns: 1fr; - } - - .chartSection, - .tableSection { - padding: 1rem; - border-radius: 8px; - } - - .chartContainer { - /* Taller than desktop's proportion would suggest: the chip legend row - sits inside, and the in-chart title/legend/axis titles are gone, so - nearly all of this is plot area. */ - height: 340px; - } - - .comparisonTable { - font-size: 0.875rem; - } - - .comparisonTable th, - .comparisonTable td { - padding: 0.75rem 0.5rem; - } - - .latestValue { - margin-left: -1rem; - margin-right: -1rem; - margin-bottom: -1rem; - padding: 1rem; - border-radius: 0 0 8px 5px; - } +.footnote { + font-size: 0.78rem; + color: var(--text-muted, #6d685f); + margin-top: 2.5rem; + border-top: 1px solid var(--border-light, #e5dfd5); + padding-top: 1rem; + max-width: 75ch; } diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index e1f929d..4e26d89 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -1,47 +1,38 @@ /** - * ComparisonView Component - * Client-side comparison interface with phase tabs, charts, and tables + * ComparisonView — the parent-first compare screen: a sticky school bar and + * six sections (At a glance / Ofsted / Academics / Getting a place / Who + * goes there / Explore trends), every number anchored against the England + * average or the computed state-school benchmark with provenance-correct + * labels. Layout and copy follow the reviewed mockups + * (docs/superpowers/specs/mockups/). */ 'use client'; import { useEffect, useRef, useState } from 'react'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; -import dynamic from 'next/dynamic'; import { useComparison } from '@/hooks/useComparison'; -const ComparisonChart = dynamic( - () => import('./ComparisonChart').then((m) => m.ComparisonChart), - { ssr: false }, -); import { SchoolSearchModal } from './SchoolSearchModal'; import { EmptyState } from './EmptyState'; -import { LoadingSkeleton } from './LoadingSkeleton'; -import type { ComparisonData, MetricDefinition, School } from '@/lib/types'; -import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils'; +import { CompareAtAGlance } from './compare/CompareAtAGlance'; +import { CompareOfsted } from './compare/CompareOfsted'; +import { CompareAcademics } from './compare/CompareAcademics'; +import { CompareAdmissions } from './compare/CompareAdmissions'; +import { CompareCommunity } from './compare/CompareCommunity'; +import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer'; +import type { + Benchmarks, + ComparisonData, + MetricDefinition, + NationalAverages, + School, +} from '@/lib/types'; +import { CHART_COLORS, schoolUrl } from '@/lib/utils'; import { fetchComparison } from '@/lib/api'; import { track } from '@/lib/analytics'; import styles from './ComparisonView.module.css'; -const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends']; -const SECONDARY_CATEGORIES = ['gcse']; - -const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [ - { label: 'Expected Standard', category: 'expected' }, - { label: 'Higher Standard', category: 'higher' }, - { label: 'Progress Scores', category: 'progress' }, - { label: 'Average Scores', category: 'average' }, - { label: 'Gender Performance', category: 'gender' }, - { label: 'Equity (Disadvantaged)', category: 'equity' }, - { label: 'School Context', category: 'context' }, - { label: 'Absence', category: 'absence' }, - { label: '3-Year Trends', category: 'trends' }, -]; - -const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [ - { label: 'GCSE Performance', category: 'gcse' }, -]; - interface ComparisonViewProps { initialData: Record | null; initialUrns: number[]; @@ -58,11 +49,13 @@ export function ComparisonView({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison(); + const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison(); const [selectedMetric, setSelectedMetric] = useState(initialMetric); const [isModalOpen, setIsModalOpen] = useState(false); const [comparisonData, setComparisonData] = useState(initialData); + const [nationalAverages, setNationalAverages] = useState(); + const [benchmarks, setBenchmarks] = useState(); const [shareConfirm, setShareConfirm] = useState(false); const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); // Tracks whether the user has explicitly clicked a phase tab. @@ -77,18 +70,18 @@ export function ComparisonView({ if (!isInitialized) return; if (initialUrns.length > 0 && initialData) { const urlSchools = initialUrns - .map(urn => initialData[String(urn)]?.school_info) + .map((urn) => initialData[String(urn)]?.school_info) .filter((info): info is NonNullable => Boolean(info)); const sameSet = urlSchools.length === selectedSchools.length && - urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn)); + urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn)); if (urlSchools.length > 0 && !sameSet) { replaceSchools(urlSchools); } } }, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps - // Sync URL with selected schools + // Sync URL with selected schools + metric, and (re)fetch the comparison. useEffect(() => { const urns = selectedSchools.map((s) => s.urn).join(','); const params = new URLSearchParams(searchParams); @@ -104,20 +97,23 @@ export function ComparisonView({ const newUrl = `${pathname}?${params.toString()}`; router.replace(newUrl, { scroll: false }); - // Fetch comparison data if (selectedSchools.length > 0) { fetchComparison(urns, { cache: 'no-store' }) .then((data) => { setComparisonData(data.comparison); + setNationalAverages(data.national_averages); + setBenchmarks(data.benchmarks); }) .catch((err) => { // Keep whatever we already have (SSR data or a previous fetch) rather - // than blanking the chart — a transient refetch failure shouldn't + // than blanking the page — a transient refetch failure shouldn't // destroy a working comparison the user is looking at. console.error('Failed to fetch comparison:', err); }); } else { setComparisonData(null); + setNationalAverages(undefined); + setBenchmarks(undefined); } }, [selectedSchools, selectedMetric, pathname, searchParams, router]); @@ -128,27 +124,22 @@ export function ComparisonView({ if (info?.rwm_expected_pct != null) return 'primary'; // Fallback: check yearly data const yearlyData = comparisonData?.[school.urn]?.yearly_data; - if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary'; + if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary'; return 'primary'; }; - const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary'); - const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary'); + const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary'); + const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary'); - // Auto-select tab with more schools and sync the metric to match the detected phase. - // This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct) - // but the shortlisted schools are secondary — the phase tab switches but the metric - // needs to follow, otherwise all secondary cards show "–" for a primary-only field. + // Auto-select tab with more schools and sync the metric to match the phase. useEffect(() => { if (!comparisonData || selectedSchools.length === 0) return; if (phaseLockedByUser.current) return; const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary'; setComparePhase(newPhase); - // Only reset the metric when it doesn't belong to the newly detected phase. - // This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score). const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES; const metricFitsPhase = metrics.some( - (m) => m.key === selectedMetric && phaseCategories.includes(m.category) + (m) => m.key === selectedMetric && phaseCategories.includes(m.category), ); if (!metricFitsPhase) { setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); @@ -158,29 +149,24 @@ export function ComparisonView({ const handlePhaseChange = (phase: 'primary' | 'secondary') => { phaseLockedByUser.current = true; setComparePhase(phase); - const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; - setSelectedMetric(defaultMetric); + setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); }; // compare_viewed: fire once after the page has its first selection. - // We watch `selectedSchools.length` going from 0 → ≥1 so the event is - // sent only when there's actual content to view, not for empty arrivals. const compareViewedRef = useRef(false); useEffect(() => { if (compareViewedRef.current) return; if (selectedSchools.length === 0) return; compareViewedRef.current = true; - const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length; + const primaryCount = selectedSchools.filter((s) => + s.phase?.toLowerCase().includes('primary'), + ).length; const secondaryCount = selectedSchools.length - primaryCount; - const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed'; + const phaseMix = + primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed'; track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix }); }, [selectedSchools]); - const handleMetricChange = (metric: string) => { - track('compare_metric_changed', { metric, phase: comparePhase }); - setSelectedMetric(metric); - }; - const handleRemoveSchool = (urn: number) => { removeSchool(urn); track('compare_school_removed', { urn, from: 'compare' }); @@ -191,21 +177,22 @@ export function ComparisonView({ const count = selectedSchools.length; const shareData = { title: 'School comparison · SchoolCompare', - text: count > 0 - ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` - : 'SchoolCompare', + text: + count > 0 + ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` + : 'SchoolCompare', url, }; - // Prefer the native share sheet on platforms that support it (iOS / Android). - // canShare is feature-detected because Safari iOS exposes share() but - // some configurations refuse the payload. - if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { + if ( + typeof navigator !== 'undefined' && + navigator.share && + (!navigator.canShare || navigator.canShare(shareData)) + ) { try { await navigator.share(shareData); track('compare_shared', { method: 'native', school_count: count }); return; } catch (err) { - // User cancelled — bail silently. Any other error falls through to clipboard. if ((err as DOMException)?.name === 'AbortError') return; } } @@ -214,27 +201,22 @@ export function ComparisonView({ track('compare_shared', { method: 'clipboard', school_count: count }); setShareConfirm(true); setTimeout(() => setShareConfirm(false), 2000); - } catch { /* fallback: do nothing */ } + } catch { + /* fallback: do nothing */ + } }; const isPrimary = comparePhase === 'primary'; - const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; - const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; - const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category)); const activeSchools = isPrimary ? primarySchools : secondarySchools; - // Get metric definition - const currentMetricDef = metrics.find((m) => m.key === selectedMetric); - const metricLabel = currentMetricDef?.label || selectedMetric; - - // No schools selected if (selectedSchools.length === 0) { return (

Compare Schools

- Add schools to your comparison basket to see side-by-side performance data + Add schools to your comparison basket to see them side by side — inspection results, + academics, admissions and community.

@@ -252,39 +234,46 @@ export function ComparisonView({ ); } - // Build filtered comparison data for active phase + // Build filtered comparison data for the active phase const activeComparisonData: Record = {}; if (comparisonData) { - activeSchools.forEach(s => { + activeSchools.forEach((s) => { if (comparisonData[s.urn]) { activeComparisonData[s.urn] = comparisonData[s.urn]; } }); } - - // Get years for table - const years = - Object.keys(activeComparisonData).length > 0 - ? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year) - : []; + const hasData = Object.keys(activeComparisonData).length > 0; return (
- {/* Header */}

Compare Schools

- Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} + {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side + — each number anchored against the England average so you can tell at a glance + what's typical and what stands out.

-
+
@@ -292,20 +281,22 @@ export function ComparisonView({
{/* Phase Tabs */} -
- - -
+ {secondarySchools.length > 0 && primarySchools.length > 0 && ( +
+ + +
+ )} {activeSchools.length === 0 ? ( ) : ( <> - {/* Metric Selector */} -
- - - {currentMetricDef?.description && ( -

{currentMetricDef.description}

- )} -
- - {/* Progress score explanation */} - {selectedMetric.includes('progress') && ( -

- Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average. -

- )} - - {/* School Cards */} -
-
- {activeSchools.map((school, index) => ( -
- -

- {school.school_name} -

-
- {school.local_authority && ( - {school.local_authority} - )} - {school.school_type && ( - {school.school_type} - )} -
- - {/* Latest metric value */} - {activeComparisonData[school.urn] && ( -
-
{metricLabel}
- {/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */} -
- - {(() => { - const yearlyData = activeComparisonData[school.urn].yearly_data; - if (yearlyData.length === 0) return '-'; - - const latestData = yearlyData[yearlyData.length - 1]; - const value = latestData[selectedMetric as keyof typeof latestData]; - - if (value === null || value === undefined) return '-'; - - if (selectedMetric.includes('progress')) { - return formatProgress(value as number); - } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { - return formatPercentage(value as number); - } else { - return typeof value === 'number' ? value.toFixed(1) : String(value); - } - })()} -
-
- )} -
- ))} -
-
- - {/* Comparison Chart */} - {Object.keys(activeComparisonData).length > 0 ? ( -
-

Performance Over Time

-
- + {activeSchools.map((school, index) => ( +
+
-
- ) : activeSchools.length > 0 ? ( -
- -
- ) : null} + ))} +
- {/* Comparison Table */} - {Object.keys(activeComparisonData).length > 0 && years.length > 0 && ( -
-

Detailed Comparison

-
- - - - - {activeSchools.map((school) => ( - - ))} - - - - {years.map((year) => ( - - - {activeSchools.map((school) => { - const schoolData = activeComparisonData[school.urn]; - if (!schoolData) return ; + {hasData && ( + <> + + + + + + - const yearData = schoolData.yearly_data.find((d) => d.year === year); - if (!yearData) return ; - - const value = yearData[selectedMetric as keyof typeof yearData]; - - if (value === null || value === undefined) { - return ; - } - - let displayValue: string; - if (selectedMetric.includes('progress')) { - displayValue = formatProgress(value as number); - } else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) { - displayValue = formatPercentage(value as number); - } else { - displayValue = typeof value === 'number' ? value.toFixed(1) : String(value); - } - - return ; - })} - - ))} - -
Year{school.school_name}
{formatAcademicYear(year)}---{displayValue}
-
-
+

+ Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection + outcomes, DfE school admissions data, school census. England averages for test + results are official DfE figures; other benchmarks are state-school averages + computed from our dataset. Following DfE practice, figures based on 5 or fewer + pupils are suppressed and shown as "no data". +

+ )} )} - {/* School Search Modal */} setIsModalOpen(false)} />
); diff --git a/nextjs-app/components/compare/TrendsExplorer.tsx b/nextjs-app/components/compare/TrendsExplorer.tsx index c4ffd64..6cd5a6e 100644 --- a/nextjs-app/components/compare/TrendsExplorer.tsx +++ b/nextjs-app/components/compare/TrendsExplorer.tsx @@ -7,7 +7,6 @@ 'use client'; -import { useState } from 'react'; import dynamic from 'next/dynamic'; import { progressBand } from '@/lib/compareLogic'; @@ -53,19 +52,20 @@ export function TrendsExplorer({ schools, data, metrics, - initialMetric, + metric, + onMetricChange, isPrimaryPhase, nationalAverages, }: { schools: School[]; data: Record; metrics: MetricDefinition[]; - initialMetric: string; + /** Controlled: the page owns the metric so the URL contract survives. */ + metric: string; + onMetricChange: (metric: string) => void; isPrimaryPhase: boolean; nationalAverages?: NationalAverages; }) { - const [metric, setMetric] = useState(initialMetric); - const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES; const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS; const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category)); @@ -88,7 +88,7 @@ export function TrendsExplorer({ const handleMetricChange = (next: string) => { track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' }); - setMetric(next); + onMetricChange(next); }; const ciKeys = PROGRESS_CI[metric]; From 2f85b9c64760467315f20bd56dd28c2228ede36d Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:59:22 +0100 Subject: [PATCH 09/11] test(e2e): compare journeys for the parent-first redesign Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- e2e/tests/journeys.spec.ts | 47 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index cf68899..936dfe2 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -138,7 +138,7 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page }) await expect(openFs).toBeVisible(); }); -test('comparing two schools shows both side by side', async ({ page }) => { +test('comparing two schools shows the parent-first sections side by side', async ({ page }) => { // Collect two school URNs from search results, then load the share URL await searchByName(page, 'primary'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); @@ -152,6 +152,36 @@ test('comparing two schools shows both side by side', async ({ page }) => { // Both schools' detail links should render in the comparison view await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); + + // The parent-first sections render in order (data-invariant: headings only) + for (const heading of [ + 'At a glance', + 'Ofsted inspection', + /How (children|students) do academically/, + 'Who goes there', + 'Explore trends', + ]) { + await expect( + page.getByRole('heading', { name: heading }).first(), + ).toBeVisible({ timeout: 15_000 }); + } + + // Every number gets an anchor: at least one England-average tick or label + await expect(page.getByText(/England \d+/).first()).toBeVisible(); + + // Ofsted linkout goes to the school's provider page, never a report deep-link + const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first(); + await expect(ofstedLink).toBeVisible(); + expect(await ofstedLink.getAttribute('href')).toMatch( + /reports\.ofsted\.gov\.uk\/provider\/21\/\d+/ + ); + + // A school never shows both an overall-grade badge AND report-card detail: + // "Report card" implies "no overall grade is given" copy is present too. + const reportCards = await page.getByText('Report card', { exact: true }).count(); + if (reportCards > 0) { + await expect(page.getByText(/no overall grade/i).first()).toBeVisible(); + } }); test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => { @@ -170,9 +200,22 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa expect(urns.length).toBeGreaterThanOrEqual(3); await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`); + + // Mobile is measure-first: the At a glance section stacks all active-phase + // schools inside one flow — no horizontal swiping between school columns. + await expect( + page.getByRole('heading', { name: 'At a glance' }), + ).toBeVisible({ timeout: 15_000 }); + const body = page.locator('body'); + const bodyOverflowsX = await body.evaluate( + (el) => el.scrollWidth > el.clientWidth + 1, + ); + expect(bodyOverflowsX).toBe(false); + + // The trends chart still renders (inside the Explore trends section)… await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); - // The mobile chart legend renders one chip per school in the active phase. + // …with the mobile chart legend chips and tap-to-focus behaviour intact. const chipGroup = page.getByRole('group', { name: /highlight a school/i }); const chips = chipGroup.getByRole('button'); await expect(chips.first()).toBeVisible({ timeout: 15_000 }); From d0e71e2cf006eb364053c1ba6ffe964d0aa50e73 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 23:46:12 +0100 Subject: [PATCH 10/11] feat(api): compare school_info carries GIAS facts for the community section Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- backend/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/app.py b/backend/app.py index df0df38..e01c4c5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -706,6 +706,15 @@ async def compare_schools( "phase": latest.get("phase", ""), "attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None, "rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None, + # GIAS facts the compare "Who goes there" section needs + # (same fields the detail endpoint exposes) + "religious_denomination": convert_to_native(latest.get("religious_denomination")), + "age_range": convert_to_native(latest.get("age_range")), + "gender": convert_to_native(latest.get("gender")), + "has_sixth_form": convert_to_native(latest.get("has_sixth_form")), + "capacity": convert_to_native(latest.get("capacity")), + "gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")), + "trust_name": convert_to_native(latest.get("trust_name")), }, "yearly_data": clean_for_json(school_data), **supplementary_by_urn.get(urn, dict(_EMPTY_SUPPLEMENTARY)), From 6dd9b04b50bee146682da87efad8fc8b526251c5 Mon Sep 17 00:00:00 2001 From: Tudor Date: Tue, 14 Jul 2026 07:07:56 +0100 Subject: [PATCH 11/11] ci: re-run PR checks (AI review job errored without posting findings) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB