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; + }); +}