Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
22 KiB
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-widthenhancement. - URL contract unchanged:
?urns=(andmetric=now scoped to Explore trends) keep working; share flow,useComparisonbasket, phase tabs, andcompare_viewed/compare_metric_changedanalytics 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/compareconsumers 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(extendSchoolResult,ComparisonData,ComparisonResponse— located around lines 293-314)
Interfaces (produced for every later task):
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<string, ReportCardEntry>;
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<string, number>;
secondary: Record<string, number>;
by_year: Array<{ year: number; primary: Record<string, number>; secondary: Record<string, number> }>;
}
- Step 1: Add the interfaces above; extend
ComparisonDatawith optionalofsted?: OfstedBlock | null; census?: CensusBlock | null; admissions?: AdmissionsRow | null; admissions_history?: AdmissionsRow[]; deprivation?: DeprivationBlock | null;andComparisonResponsewithnational_averages?: NationalAverages; benchmarks?: Benchmarks;(optional so the UI degrades on an old backend). ExtendSchoolResultwith 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):
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<number, string>; // 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<number | null>, 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 textOver 1 in 4 first choices missed out; 97% → good chip97% of first choices offered; 100% →All first choices offered; interest stringNamed 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 +tscclean. 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:
export interface DotStripProps {
label: string;
values: Array<number | null>; // 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-rowanatomy: label row, 4px track, England tick + tick label, 16px dots coloured byCHART_COLORS[index]with white ring, value labels below (flipped above on collision viastripPositions).role="img"+aria-labelenumerating 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.tsxfor 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<string, ComparisonData>; 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 rendersReportCardSummarychips — counts best-first + named problem chips + safeguarding line); expected-standard row (big % +verdictchip vsnational_averages.primary.rwm_expected_pct, small "England average N%"); Getting a place row (chip fromsummariseAdmissions, note =interest); Size row (pupils + "at or above capacity"/"N% full" from census/capacity, vsbenchmarks.*.median_pupilsfor "larger/smaller than average" phrasing). -
Ofsted: the section's
howparagraph (regime explanation + non-comparability + "Expected standard" disambiguation) verbatim from the desktop mockup; Result row; Inspected row (date + "4+ years ago" chip when >4y, computed frominspection_date); Judgement detail row — one chip-list grammar for both regimes (legacy subgrades viaOFSTED_LEGACY_GRADES; report card viareport_cardlabels; "We don't hold area-by-area detail for this inspection" when neither); Ofsted page row linkingofsted_page_url("'s Ofsted page →"). -
Getting a place:
howparagraph (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
benchmarkswith "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;
tscclean;npm testfull 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.tswith the metric-extraction helper below
Interfaces:
-
Add to
compareLogic.ts:latestValues(data, urns, metricKey) => Array<number|null>(latest non-null yearly value per school) — tested. -
Tier 1 strips (always visible), each a
DotStripwith the England anchor fromnational_averages.primary: RWM expected, Reading, Writing, Maths, "Working at a higher standard than expected" (tooltip: composition sentence from the mockups). Sectionhowline: "tests and teacher assessments … writing is assessed by teachers, not tested" verbatim. -
Tier 2
<details>"More measures — grammar, punctuation & spelling, science, average scaled scores": GPS + Science (teacher-assessed, tooltip verbatim) with anchors fromnational_averageswhen present, no tick + honest note when null; scaled scores (reading/maths/GPS) onmin=100 max=120with the mockups' window caption. -
Equity row: disadvantaged pupils' RWM per school + chip vs
benchmarks.primary.disadvantaged_rwm_expected_pctwith 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 showingprogress_8_bandingverbatim — 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_datayear values are floats (202425.0) vs the labels array — the suspect is the year-matching inComparisonChart.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<number, number|null>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:falseon school datasets so dataset gaps break lines. -
Step 4:
TrendsExplorerwraps the grouped metric picker (existing optgroup structure andmetricsfrom/api/metrics, existing analytics event) + the chart + the existing year-by-year table, inside a collapsed-by-default<details>("Explore trends"). Progress metrics annotate cells withprogressBandchips 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.tsxmetadata description (mention Ofsted/admissions, not just KS2) -
Preserve intact:
useComparisonbasket seeding/URL sync (lines 76-122 of the current file), share handler, phase tabs + auto-detection,compare_viewedanalytics, 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-levelmetricURL param now initialisesTrendsExplorer'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 testfull suite +tscclean. 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 stringstate-school averagesomewhere (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_COLORSswap to the validated trio is a candidate follow-up, flagged not included — it affects every chart in the app).