PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
/**
|
||
* 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;
|
||
});
|
||
}
|