PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
A parent choosing a primary and a secondary school at once hit the old cap of five total. The basket now holds up to five primary and five secondary schools (ten in all), matching the compare page's phase tabs. Schools that could land in either tab (all-through, special schools with phase "Not applicable", unknown phase) count against both groups, so no tab ever exceeds the five-slot chart palette and point styles. - lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits - search modal disables only the full group and says which one - rankings rows carry the phase of the tab they are ranked under - shared ?urns= links are trimmed per group - copy: compare metadata, homepage value prop, How it works card now name primary and secondary schools (also better for search intent) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
12 KiB
TypeScript
336 lines
12 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;
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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.
|
||
if (p.includes('secondary')) 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]),
|
||
[],
|
||
);
|
||
}
|