Files
school_compare/nextjs-app/lib/compareLogic.ts
TudorandClaude Opus 5.5 587cfe3f0b
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 1m6s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
fix(compare): give every basket entry a real phase
Review of the per-phase limit found entries reaching the basket with no
phase, and a phase-less entry holds a slot in both groups:

- Nearby-school cards added without one. The API now returns each
  peer's own phase (its pool is a phase group, so an all-through school
  can sit beside a primary); the button passes it through, and an older
  API simply leaves the conservative both-groups count in place.
- Baskets saved before this change were never migrated. The compare
  page now backfills missing phases from the data it already fetches,
  and never overwrites a phase the basket has.
- "16 plus" counted against both groups; it is secondary, as the API's
  PHASE_GROUPS files it.

Also rewraps the HomeView doc comment the previous commit left awkward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:04:39 +01:00

337 lines
12 KiB
TypeScript
Raw Permalink 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'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'transitional' }
| { 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) {
if (ofsted.inspection_date) {
return { kind: 'transitional' };
}
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;
}
/**
* 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]),
[],
);
}