diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts index 1437deb..ce217a4 100644 --- a/nextjs-app/__tests__/lib/compareLogic.test.ts +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -114,16 +114,13 @@ describe('ofstedDisplay', () => { expect(d.kind).toBe('report_card'); }); - it('distinguishes graded from carried-forward grades', () => { - const graded = ofstedDisplay( - ofsted({ overall_effectiveness: 1, grade_source: 'graded' }), - ); - expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false }); - - const carried = ofstedDisplay( - ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }), - ); - expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true }); + it('distinguishes a graded grade from a confirmed one', () => { + expect(ofstedDisplay(ofsted({ current_grade: { grade: 1, date: '2019-10-09', basis: 'graded' }, + latest_visit: { date: '2025-02-05', kind: 'ungraded', outcome: 'Some aspects not as strong' } }))) + .toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', gradeDate: '2019-10-09' }); + expect(ofstedDisplay(ofsted({ current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' }, + latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } }))) + .toMatchObject({ kind: 'confirmed', gradeLabel: 'Good' }); }); it('handles missing data', () => { @@ -131,11 +128,11 @@ describe('ofstedDisplay', () => { expect(ofstedDisplay(ofsted({})).kind).toBe('none'); }); - it('identifies transitional inspections without overall grades', () => { - const transitional = ofstedDisplay( - ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }), - ); - expect(transitional.kind).toBe('transitional'); + it('has no overall grade when the latest inspection gave none', () => { + // Rabbsfarm: the graded inspection's own overall is null, so no grade is + // in force even though an older ungraded visit said "remains Good". + expect(ofstedDisplay(ofsted({ current_grade: null, overall_effectiveness: null, + latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).kind).toBe('no_overall_grade'); }); it('uses the four legacy grade words', () => { diff --git a/nextjs-app/__tests__/lib/ofstedStatus.test.ts b/nextjs-app/__tests__/lib/ofstedStatus.test.ts new file mode 100644 index 0000000..86deadc --- /dev/null +++ b/nextjs-app/__tests__/lib/ofstedStatus.test.ts @@ -0,0 +1,47 @@ +import { gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus'; +import type { OfstedInspection } from '@/lib/types'; + +const base = { report_card: {} } as unknown as OfstedInspection; + +describe('gradeSourceLine', () => { + it('names the graded inspection and its date', () => { + expect(gradeSourceLine({ grade: 2, date: '2016-07-06', basis: 'graded' })) + .toBe('Graded inspection, 6 July 2016'); + }); + it('names the ungraded visit that confirmed it', () => { + expect(gradeSourceLine({ grade: 2, date: '2023-03-14', basis: 'confirmed' })) + .toBe('Confirmed at an ungraded inspection, 14 March 2023'); + }); +}); + +describe('latestVisitLine', () => { + it('prints the ungraded outcome', () => { + expect(latestVisitLine({ date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' })) + .toBe('Ungraded inspection, 13 November 2024: Standards maintained'); + }); + it('prints a graded visit without an outcome', () => { + expect(latestVisitLine({ date: '2025-06-17', kind: 'graded', outcome: null })) + .toBe('Graded inspection, 17 June 2025'); + }); +}); + +describe('showLatestVisitLine', () => { + it('shows a later visit than the one the grade came from', () => { + expect(showLatestVisitLine({ ...base, + current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' }, + latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true); + }); + it('hides it when the visit is the grade’s own source', () => { + expect(showLatestVisitLine({ ...base, + current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' }, + latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })).toBe(false); + }); + it('shows an ungraded outcome when there is no grade', () => { + expect(showLatestVisitLine({ ...base, current_grade: null, + latest_visit: { date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true); + }); + it('hides it for a graded visit with no grade (the title already dates it)', () => { + expect(showLatestVisitLine({ ...base, current_grade: null, + latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false); + }); +}); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index 9fb5042..ab03561 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -147,23 +147,37 @@ describe('ofstedLegacyAreas', () => { describe('buildOfstedListBadge', () => { it('returns grade word + year for OEIF Outstanding', () => { - const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' }); + const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' }); expect(badge.label).toBe('Outstanding · 2023'); expect(badge.cssClass).toBe('ofsted1'); }); it('returns grade word for each OEIF grade', () => { - expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022'); - expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021'); - expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020'); + expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-05-01' }).label).toBe('Good · 2022'); + expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_grade_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021'); + expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_grade_date: '2020-03-01' }).label).toBe('Inadequate · 2020'); }); - it('returns grade word without year when date is missing', () => { - const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null }); + it('prints a grade without a year when its date is missing', () => { + const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: null, ofsted_date: '2025-01-01' }); expect(badge.label).toBe('Good'); expect(badge.cssClass).toBe('ofsted2'); }); + it('dates a grade by the inspection that awarded it, not the latest visit', () => { + // Washwood Heath: Good from a March 2020 graded inspection; latest visit + // an ungraded one in May 2025 (audit M1). + const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2020-03-03', ofsted_date: '2025-05-21' }); + expect(badge.label).toBe('Good · 2020'); + }); + + it('shows Inspected for a latest inspection that gave no grade (Rabbsfarm)', () => { + // Audit C1: this used to read "Good · 2025", from a 2020 ungraded visit. + const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_grade_date: null, ofsted_date: '2025-06-17' }); + expect(badge.label).toBe('Inspected · 2025'); + expect(badge.cssClass).toBe('ofstedInspected'); + }); + it('returns a Report Card badge when ofsted_rc_date is present', () => { const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' }); expect(badge.label).toBe('Report Card · 2026'); diff --git a/nextjs-app/lib/compareLogic.ts b/nextjs-app/lib/compareLogic.ts index a10e1a7..9b7b9a9 100644 --- a/nextjs-app/lib/compareLogic.ts +++ b/nextjs-app/lib/compareLogic.ts @@ -100,9 +100,8 @@ export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary 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: 'graded' | 'confirmed'; grade: number; gradeLabel: string; gradeDate: string | null } + | { kind: 'no_overall_grade' } | { kind: 'report_card'; summary: ReportCardSummary }; export function ofstedDisplay( @@ -116,19 +115,19 @@ export function ofstedDisplay( 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' }; + // The grade still in force (backend: fact_ofsted_latest), never one carried + // past a later inspection that gave none. + const current = ofsted.current_grade; + const gradeLabel = current ? OFSTED_LEGACY_GRADES[current.grade] : undefined; + if (current && gradeLabel !== undefined) { + return { + kind: current.basis === 'confirmed' ? 'confirmed' : 'graded', + grade: current.grade, + gradeLabel, + gradeDate: current.date, + }; } - - if (ofsted.grade_source === 'ungraded_carried_forward') { - return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; - } - return { kind: 'graded', grade, gradeLabel, carriedForward: false }; + return ofsted.latest_visit ? { kind: 'no_overall_grade' } : { kind: 'none' }; } // --------------------------------------------------------------------------- diff --git a/nextjs-app/lib/ofstedStatus.ts b/nextjs-app/lib/ofstedStatus.ts new file mode 100644 index 0000000..f9164a5 --- /dev/null +++ b/nextjs-app/lib/ofstedStatus.ts @@ -0,0 +1,48 @@ +/** + * The sentences the school page and the compare page print about where an + * Ofsted grade came from and what the latest visit was. One wording, two pages. + * Rule: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md + */ + +import type { OfstedCurrentGrade, OfstedInspection, OfstedLatestVisit } from './types'; + +export function formatOfstedDate(iso: string | null | undefined): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +const VISIT_KIND: Record = { + report_card: 'Report card inspection', + graded: 'Graded inspection', + ungraded: 'Ungraded inspection', +}; + +/** "Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection, 14 March 2023". */ +export function gradeSourceLine(current: OfstedCurrentGrade): string { + const when = formatOfstedDate(current.date); + const what = current.basis === 'confirmed' ? 'Confirmed at an ungraded inspection' : 'Graded inspection'; + return when ? `${what}, ${when}` : what; +} + +/** "Ungraded inspection, 13 November 2024: Standards maintained". */ +export function latestVisitLine(visit: OfstedLatestVisit): string { + const head = `${VISIT_KIND[visit.kind]}, ${formatOfstedDate(visit.date)}`; + return visit.outcome ? `${head}: ${visit.outcome}` : head; +} + +/** + * Whether the latest visit needs its own line: when it is not where the grade + * came from, or when there is no grade but an ungraded outcome to report. A + * graded visit without a grade is already dated by the section title. + */ +export function showLatestVisitLine( + ofsted: Pick, +): boolean { + const visit = ofsted.latest_visit; + if (!visit || visit.kind === 'report_card') return false; + const current = ofsted.current_grade; + if (current) return current.date !== visit.date; + return visit.kind === 'ungraded'; +} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 11c5582..39ab734 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -76,10 +76,14 @@ export interface School { parliamentary_constituency?: string | null; // Ofsted (for list view — summary only) + /** The overall grade still in force; null when the latest inspection gave none. */ ofsted_grade?: 1 | 2 | 3 | 4 | null; + /** Date the grade was awarded or confirmed (null without a grade). */ + ofsted_grade_date?: string | null; /** Report-card inspection date (Nov 2025+); non-null identifies a report * card in the list/map, where the full report_card object isn't available. */ ofsted_rc_date?: string | null; + /** The school's latest inspection of any kind. */ ofsted_date?: string | null; ofsted_framework?: string | null; } @@ -95,6 +99,7 @@ export interface OfstedInspection { rc_inspection_date?: string | null; inspection_type: string | null; // OEIF fields (old framework, pre-Nov 2025) + /** The graded inspection's own overall grade; never carried forward. */ overall_effectiveness: 1 | 2 | 3 | 4 | null; quality_of_education: number | null; behaviour_attitudes: number | null; @@ -115,9 +120,12 @@ export interface OfstedInspection { rc_leadership_governance: number | null; rc_early_years: number | null; rc_sixth_form: number | null; - /** Where the effective overall grade came from: a graded (Section 5) - * inspection, or carried forward from an ungraded (Section 8) outcome. */ - grade_source?: 'graded' | 'ungraded_carried_forward' | null; + /** The overall grade still in force, dated by the inspection that awarded + * ("graded") or confirmed ("confirmed", an ungraded visit) it. Null when the + * latest inspection gave no overall grade, or for a report card. */ + current_grade?: OfstedCurrentGrade | null; + /** The school's most recent inspection of any kind. */ + latest_visit?: OfstedLatestVisit | null; /** Renewed-framework (Nov 2025) area judgements, coded + labelled by the * backend from the live-sampled Ofsted vocabulary. Empty when the school * has no report-card inspection. Safeguarding is never included here. */ @@ -127,6 +135,18 @@ export interface OfstedInspection { report_url?: string | null; } +export interface OfstedCurrentGrade { + grade: 1 | 2 | 3 | 4; + date: string | null; + basis: 'graded' | 'confirmed'; +} + +export interface OfstedLatestVisit { + date: string; + kind: 'report_card' | 'graded' | 'ungraded'; + outcome: string | null; +} + export interface ReportCardEntry { code: number; label: string; diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 8fbea04..e14492f 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -837,15 +837,18 @@ export interface OfstedListBadge { * Checked FIRST so it wins over any carried-forward legacy grade — the * list has no full report_card object, and ofsted_framework is the raw * event grouping ("Schools - S5"), never "ReportCard". - * - OEIF school (ofsted_grade set): grade word + year, colour-keyed - * - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no - * longer issues an overall judgement): "Inspected · YYYY" — mirrors the - * detail page's hero chip so a school never reads as both inspected and + * - Current grade (ofsted_grade set): grade word + the year it was awarded + * or confirmed (ofsted_grade_date), colour-keyed. Never the year of a later + * visit: that paired old grades with new inspections (audit C1). + * - Inspected with no grade in force (every inspection Sept 2024 – Nov 2025, + * or an ungraded visit whose outcome names no grade): "Inspected · YYYY", + * dated by the latest visit, so a school never reads as both inspected and * "Not yet inspected" * - No inspection on record: "Not yet inspected" in grey */ export function buildOfstedListBadge(school: { ofsted_grade?: 1 | 2 | 3 | 4 | null; + ofsted_grade_date?: string | null; ofsted_date?: string | null; ofsted_framework?: string | null; ofsted_rc_date?: string | null; @@ -858,10 +861,7 @@ export function buildOfstedListBadge(school: { return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' }; } - const year = school.ofsted_date - ? new Date(school.ofsted_date).getFullYear() - : null; - const yearStr = year ? ` · ${year}` : ''; + const yearOf = (iso?: string | null) => (iso ? new Date(iso).getFullYear() : null); if (school.ofsted_grade) { const labels: Record = { @@ -870,17 +870,19 @@ export function buildOfstedListBadge(school: { 3: 'Req. Improvement', 4: 'Inadequate', }; + const gradeYear = yearOf(school.ofsted_grade_date); return { - label: `${labels[school.ofsted_grade]}${yearStr}`, + label: `${labels[school.ofsted_grade]}${gradeYear ? ` · ${gradeYear}` : ''}`, cssClass: `ofsted${school.ofsted_grade}`, }; } - // An inspection is on record (date or framework present) but carries no - // overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school - // that has genuinely never been inspected. + // An inspection is on record but no overall grade is in force: every + // inspection from Sept 2024 to Nov 2025, or an ungraded visit whose outcome + // names no grade. Dated by the latest visit. if (school.ofsted_date != null || school.ofsted_framework != null) { - return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' }; + const visitYear = yearOf(school.ofsted_date); + return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' }; } return { label: 'Not yet inspected', cssClass: 'ofstedPending' };