Files
school_compare/nextjs-app/lib/compareLogic.ts
T
TudorandClaude Opus 5.5 a42c586cc8 fix(ofsted): badge and compare read the grade still in force
Search badges date a grade by the inspection that awarded or confirmed it (ofsted_grade_date) and say 'Inspected · year' when the latest inspection gave no grade. ofstedDisplay's kinds become graded / confirmed / no_overall_grade, read from current_grade rather than a carried-forward overall_effectiveness. lib/ofstedStatus.ts holds the two sentences the school and compare pages share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:29:54 +01:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Requires improvement',
4: 'Inadequate',
};
/** rc_ key → the area label used across the reviewed mockups. */
const RC_AREA_LABELS: Record<string, string> = {
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<number, { label: string; count: number }>();
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' | 'confirmed'; grade: number; gradeLabel: string; gradeDate: string | null }
| { kind: 'no_overall_grade' }
| { 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) };
}
// The grade still in force (backend: fact_ofsted_latest), never one carried
// past a later inspection that gave none.
const current = ofsted.current_grade;
const gradeLabel = current ? OFSTED_LEGACY_GRADES[current.grade] : undefined;
if (current && gradeLabel !== undefined) {
return {
kind: current.basis === 'confirmed' ? 'confirmed' : 'graded',
grade: current.grade,
gradeLabel,
gradeDate: current.date,
};
}
return ofsted.latest_visit ? { kind: 'no_overall_grade' } : { kind: 'none' };
}
// ---------------------------------------------------------------------------
// 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;
}
/**
* Pick the admissions round for the ACTIVE phase tab. An all-through school
* can carry only a Year 7 (Secondary) round — rendering that beside pure
* primaries' Reception rounds made 433-forms-for-173-places read as
* Reception odds. Rows matching the target phase win (latest year first);
* rows tagged with the OTHER phase are never substituted. Untagged rows
* (legacy data, no school_phase) are used only when no row carries a phase.
*/
export function admissionsForPhase(
data:
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
| null
| undefined,
isSecondary: boolean,
): SchoolAdmissions | null {
if (!data) return null;
const rows: SchoolAdmissions[] = [
...(data.admissions_history ?? []),
...(data.admissions ? [data.admissions] : []),
];
if (rows.length === 0) return null;
const target = isSecondary ? 'secondary' : 'primary';
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
const matching = rows
.filter((r) => r.school_phase?.toLowerCase() === target)
.sort(byYearDesc);
if (matching.length > 0) return matching[0];
const tagged = rows.some((r) => r.school_phase != null);
if (!tagged) return [...rows].sort(byYearDesc)[0];
return 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 < 50) {
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
// than half missed out understated the worst cases by half.
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
} else if (pct < 67) {
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
} 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<number | null>,
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<string, { yearly_data: Array<{ year: number }> }>,
urns: number[],
metricKey: string,
): Array<number | null> {
return urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const v = (rows[i] as Record<string, unknown>)[metricKey];
if (typeof v === 'number' && !Number.isNaN(v)) return v;
}
return null;
});
}
// ---------------------------------------------------------------------------
// Basket limits — five per comparison group, not five overall
// ---------------------------------------------------------------------------
export type CompareGroup = 'primary' | 'secondary';
export const MAX_PER_GROUP = 5;
/**
* Which phase tab(s) of the compare page a school can land in. The page
* sorts by the data a school actually has, which isn't known when it's added,
* so anything that isn't plainly one phase (all-through, special schools
* with phase "Not applicable", unknown) counts against BOTH groups. That
* keeps every tab at MAX_PER_GROUP or fewer, which the five-slot chart
* palette and point styles rely on.
*/
export function compareGroups(phase?: string | null): CompareGroup[] {
const p = (phase ?? '').toLowerCase();
// "Middle deemed secondary" / "Middle deemed primary" match here too.
// "16 plus" is secondary, as the API's PHASE_GROUPS files it.
if (p.includes('secondary') || p === '16 plus') return ['secondary'];
if (p.includes('primary')) return ['primary'];
return ['primary', 'secondary'];
}
/** The group that has no room for `school`, or null if it can be added. */
export function fullGroupFor(
basket: ReadonlyArray<{ phase?: string | null }>,
school: { phase?: string | null },
): CompareGroup | null {
for (const group of compareGroups(school.phase)) {
const used = basket.filter((s) => compareGroups(s.phase).includes(group)).length;
if (used >= MAX_PER_GROUP) return group;
}
return null;
}
/** Keep schools in order, dropping any that would overflow a group. */
export function fitToGroupLimits<T extends { phase?: string | null }>(schools: T[]): T[] {
return schools.reduce<T[]>(
(kept, school) => (fullGroupFor(kept, school) ? kept : [...kept, school]),
[],
);
}