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

245 lines
8.4 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: '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;
});
}