Files
school_compare/nextjs-app/lib/compareLogic.ts
T

245 lines
8.4 KiB
TypeScript
Raw Normal View History

/**
* 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: '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 {
/** 0100 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;
});
}