Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31e71ac88 | ||
|
|
2ac26acf91 | ||
|
|
3e77057567 | ||
|
|
e0d5a9969e | ||
|
|
dc21e80a5e | ||
|
|
6d3a203699 | ||
|
|
b0c5b6bb57 | ||
|
|
a1128bd801 | ||
|
|
2fd997bfe6 | ||
|
|
bdd9bef349 | ||
|
|
e5f7f4c959 | ||
|
|
60918da483 | ||
|
|
284215fbce | ||
|
|
200a97d0b9 | ||
|
|
f3fa12806b | ||
|
|
1004f08daf | ||
|
|
79246edc22 | ||
|
|
64b63b96c8 | ||
|
|
5944d88f0b | ||
|
|
163b501be6 | ||
|
|
e8f78a1598 | ||
|
|
20a27f3958 |
@@ -740,6 +740,9 @@ async def compare_schools(
|
|||||||
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
||||||
"age_range": convert_to_native(latest.get("age_range")),
|
"age_range": convert_to_native(latest.get("age_range")),
|
||||||
"gender": convert_to_native(latest.get("gender")),
|
"gender": convert_to_native(latest.get("gender")),
|
||||||
|
# Needed by the admissions "What this means" copy: selective
|
||||||
|
# schools get entrance-test framing, never the distance template.
|
||||||
|
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
||||||
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||||
"capacity": convert_to_native(latest.get("capacity")),
|
"capacity": convert_to_native(latest.get("capacity")),
|
||||||
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||||
|
|||||||
@@ -94,6 +94,33 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
||||||
|
// Detail views detected report cards via `framework`, which the API never
|
||||||
|
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
||||||
|
// dated to a pre-Nov-2025 inspection. Detection now keys off the report_card
|
||||||
|
// object and dates it with rc_inspection_date.
|
||||||
|
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
|
||||||
|
const res = await page.request.get(`/api/schools/${RC_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const ofsted = (await res.json()).ofsted;
|
||||||
|
test.skip(
|
||||||
|
!ofsted?.report_card || Object.keys(ofsted.report_card).length === 0,
|
||||||
|
'precondition: chosen URN must currently have a report card',
|
||||||
|
);
|
||||||
|
const rcYear = new Date(ofsted.rc_inspection_date).getFullYear();
|
||||||
|
const legacyYear = new Date(ofsted.inspection_date).getFullYear();
|
||||||
|
|
||||||
|
await page.goto(`/school/${RC_URN}`);
|
||||||
|
const ofstedSection = page.locator('#ofsted');
|
||||||
|
// Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating".
|
||||||
|
await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 });
|
||||||
|
// Dating fixed: dated to the report-card inspection, never the legacy one.
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible();
|
||||||
|
if (legacyYear !== rcYear) {
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||||
@@ -237,6 +264,11 @@ test('comparing two secondary schools renders the secondary sections', async ({
|
|||||||
// A KS4 measure proves the secondary academics variant rendered.
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The admissions template must be phase-aware: the primaries' distance
|
||||||
|
// copy ("non-faith primaries") must never appear on a secondary comparison
|
||||||
|
// (expert sign-off must-fix M3).
|
||||||
|
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Secondary academics: every headline number carries its England anchor and a
|
||||||
|
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
|
||||||
|
* rendered as bare numbers, breaking the "anchored against England" promise).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAcademics } from '@/components/compare/CompareAcademics';
|
||||||
|
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, 'Test High'),
|
||||||
|
yearly_data: [
|
||||||
|
{
|
||||||
|
year: 202425,
|
||||||
|
attainment_8_score: 58.7,
|
||||||
|
english_maths_strong_pass_pct: 30,
|
||||||
|
ebacc_entry_pct: 10,
|
||||||
|
},
|
||||||
|
] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NATIONAL: NationalAverages = {
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: {
|
||||||
|
attainment_8_score: 46.0,
|
||||||
|
english_maths_strong_pass_pct: 45.4,
|
||||||
|
ebacc_entry_pct: 40.5,
|
||||||
|
},
|
||||||
|
by_year: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
|
||||||
|
const s = school(137086, 'Bishop Stopford School');
|
||||||
|
render(
|
||||||
|
<CompareAcademics
|
||||||
|
schools={[s]}
|
||||||
|
data={{ '137086': data(137086) }}
|
||||||
|
nationalAverages={NATIONAL}
|
||||||
|
isSecondary
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
|
||||||
|
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
|
||||||
|
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
|
||||||
|
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Getting a place — phase and school-type correctness (expert sign-off
|
||||||
|
* must-fixes M1/M3):
|
||||||
|
* - an all-through school's Year 7 round must never render on the primary
|
||||||
|
* tab as if it were Reception odds;
|
||||||
|
* - selective schools get entrance-test framing, and the secondary tab
|
||||||
|
* never shows the primaries' distance template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
|
||||||
|
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string, extra: Partial<School> = {}): School {
|
||||||
|
return { urn, school_name: name, ...extra } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||||
|
return {
|
||||||
|
year: 202627,
|
||||||
|
school_phase: 'Secondary',
|
||||||
|
places_offered: 173,
|
||||||
|
total_applications: 433,
|
||||||
|
first_preference_offer_pct: 83,
|
||||||
|
oversubscribed: true,
|
||||||
|
...partial,
|
||||||
|
} as SchoolAdmissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: info,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: a,
|
||||||
|
admissions_history: a ? [a] : [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CompareAdmissions', () => {
|
||||||
|
it("does not show an all-through school's Year 7 round on the primary tab", () => {
|
||||||
|
// The real M1 scenario: an all-through school (Year 7 round only) beside
|
||||||
|
// a primary with a Reception round.
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const primary = school(138690, 'Barclay Primary School');
|
||||||
|
const data = {
|
||||||
|
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
|
||||||
|
'138690': entry(
|
||||||
|
primary,
|
||||||
|
admissions({
|
||||||
|
school_phase: 'Primary',
|
||||||
|
total_applications: 300,
|
||||||
|
places_offered: 120,
|
||||||
|
first_preference_offer_pct: 96,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
// Hessle's Year 7 figures must not appear…
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/We don't hold Reception admissions data for this school/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// …while Barclay's Reception round renders normally.
|
||||||
|
expect(screen.getByText('300')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase-labels the section empty state when no matching round exists at all', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No Reception admissions data is available for these schools yet/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Year 7 round on the secondary tab', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('433')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('173')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives selective schools entrance-test framing, never the distance template', () => {
|
||||||
|
const grammar = school(136276, 'Watford Grammar School for Boys', {
|
||||||
|
admissions_policy: 'Selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
|
||||||
|
const faithSchool = school(102052, "Bishop Stopford's School", {
|
||||||
|
admissions_policy: 'Non-selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
|
||||||
|
const primary = school(100140, 'Plumcroft Primary School');
|
||||||
|
const data = {
|
||||||
|
'100140': entry(
|
||||||
|
primary,
|
||||||
|
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,31 @@ describe('CompareOfsted', () => {
|
|||||||
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
|
||||||
|
const sentinelSchool = school(6, 'Sentinel School');
|
||||||
|
const sentinelData: Record<string, ComparisonData> = {
|
||||||
|
'6': {
|
||||||
|
school_info: sentinelSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
overall_effectiveness: 2,
|
||||||
|
grade_source: 'graded',
|
||||||
|
quality_of_education: 1,
|
||||||
|
early_years_provision: 9,
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
|
||||||
|
// Real grades render…
|
||||||
|
expect(screen.getByText('Quality of education')).toBeInTheDocument();
|
||||||
|
// …the applicable sixth-form judgement renders (was previously dropped)…
|
||||||
|
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
|
||||||
|
// …and the not-applicable sentinel never appears, neither as area nor code.
|
||||||
|
expect(screen.queryByText('Early years provision')).toBeNull();
|
||||||
|
expect(screen.queryByText('9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
const cardSchool = school(4, 'Dated Card School');
|
const cardSchool = school(4, 'Dated Card School');
|
||||||
const cardData: Record<string, ComparisonData> = {
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
|
|||||||
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists England-only years so the component can caption dashed-only stretches', () => {
|
||||||
|
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
|
202122: 58.7,
|
||||||
|
});
|
||||||
|
expect(chart.englandOnlyYears).toEqual([202122]);
|
||||||
|
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||||
|
expect(none.englandOnlyYears).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
||||||
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
202122: 58.7,
|
202122: 58.7,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
OFSTED_LEGACY_GRADES,
|
OFSTED_LEGACY_GRADES,
|
||||||
|
admissionsForPhase,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
progressBand,
|
progressBand,
|
||||||
rcAreaLabel,
|
rcAreaLabel,
|
||||||
@@ -176,6 +177,16 @@ describe('summariseAdmissions', () => {
|
|||||||
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('60% → "About 1 in 3 first choices missed out"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
|
||||||
|
});
|
||||||
|
|
||||||
it('100% → "All first choices offered"', () => {
|
it('100% → "All first choices offered"', () => {
|
||||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
||||||
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
||||||
@@ -188,6 +199,44 @@ describe('summariseAdmissions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admissionsForPhase', () => {
|
||||||
|
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
|
||||||
|
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
|
||||||
|
|
||||||
|
it('returns the latest round matching the active phase', () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(data, false)?.year).toBe(202526);
|
||||||
|
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses untagged legacy rows only when no row carries a phase', () => {
|
||||||
|
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
|
||||||
|
|
||||||
|
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(mixed, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing data', () => {
|
||||||
|
expect(admissionsForPhase(null, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('progressBand', () => {
|
describe('progressBand', () => {
|
||||||
it('CI entirely above zero → above', () => {
|
it('CI entirely above zero → above', () => {
|
||||||
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||||
|
|||||||
@@ -183,10 +183,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: teal outline — supporting actions (+ Compare) */
|
/* Secondary: teal outline — supporting actions (+ Compare) */
|
||||||
|
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
|
||||||
|
which wins over the base `.btn`'s `1px solid transparent`. The outline
|
||||||
|
variants below therefore declare the full `border` shorthand explicitly so
|
||||||
|
they don't depend on the base border-width — otherwise `border-color` alone
|
||||||
|
has no width and the outline never renders (buttons read as plain text). */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
@@ -196,7 +201,7 @@ body {
|
|||||||
.btn-tertiary {
|
.btn-tertiary {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.btn-tertiary:hover:not(:disabled) {
|
.btn-tertiary:hover:not(:disabled) {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
@@ -207,7 +212,7 @@ body {
|
|||||||
.btn-active {
|
.btn-active {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-active:hover:not(:disabled) {
|
.btn-active:hover:not(:disabled) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
|
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
|
||||||
parent .chartContainer, whose fixed height drives Chart.js sizing via
|
The canvas has its OWN definite height (Chart.js needs one for
|
||||||
maintainAspectRatio: false. */
|
maintainAspectRatio: false); the chips and the note flow at their natural
|
||||||
|
size around it rather than competing with it for a fixed outer height —
|
||||||
|
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
|
||||||
|
never squash the chart. */
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasBox {
|
.canvasBox {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
height: 380px;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.canvasBox {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
||||||
|
|||||||
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
|
|||||||
/** Official England figure per academic year for this metric — renders a
|
/** Official England figure per academic year for this metric — renders a
|
||||||
* dashed grey reference line when provided. */
|
* dashed grey reference line when provided. */
|
||||||
nationalByYear?: Record<number, number | null | undefined>;
|
nationalByYear?: Record<number, number | null | undefined>;
|
||||||
|
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
||||||
|
isSecondary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||||
// converging lines stay tellable apart without relying on hue alone.
|
// converging lines stay tellable apart without relying on hue alone.
|
||||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||||
|
|
||||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
display: true,
|
display: true,
|
||||||
title: {
|
title: {
|
||||||
display: !isMobile,
|
display: !isMobile,
|
||||||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
|
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: 12,
|
||||||
weight: 'bold',
|
weight: 'bold',
|
||||||
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
<div className={styles.canvasBox}>
|
<div className={styles.canvasBox}>
|
||||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||||
</div>
|
</div>
|
||||||
{built.showUnpublished202122Note && (
|
{isSecondary && built.englandOnlyYears.length > 0 ? (
|
||||||
|
// KS4's honest story differs from KS2's: 2019/20–2020/21 school-level
|
||||||
|
// GCSE results weren't published (COVID grading); later years WERE
|
||||||
|
// published by DfE but aren't in our dataset yet.
|
||||||
|
<p className={styles.chartNote}>
|
||||||
|
School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID
|
||||||
|
grading), and more recent years aren't in our dataset yet where lines break — the
|
||||||
|
England average is shown where available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
!isSecondary &&
|
||||||
|
built.showUnpublished202122Note && (
|
||||||
<p className={styles.chartNote}>
|
<p className={styles.chartNote}>
|
||||||
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
||||||
school-level figures for 2021/22 — the England average is shown for that year.
|
school-level figures for 2021/22 — the England average is shown for that year.
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -365,12 +365,18 @@ export function ComparisonView({
|
|||||||
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||||||
aria-label="Schools in this comparison"
|
aria-label="Schools in this comparison"
|
||||||
>
|
>
|
||||||
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
|
{/* Fills the 200px label rail on desktop (hidden on mobile).
|
||||||
|
All-through schools must not be miscounted as "primary
|
||||||
|
schools"/"secondary schools" — mixed baskets get "· primary
|
||||||
|
view" phrasing instead. */}
|
||||||
<div className={styles.barCaption}>
|
<div className={styles.barCaption}>
|
||||||
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||||||
<span className={styles.barCaptionCount}>
|
<span className={styles.barCaptionCount}>
|
||||||
{activeSchools.length} {comparePhase} school
|
{activeSchools.every((sch) =>
|
||||||
{activeSchools.length === 1 ? '' : 's'}
|
sch.phase?.toLowerCase().includes(comparePhase),
|
||||||
|
)
|
||||||
|
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
|
||||||
|
: `${activeSchools.length} schools · ${comparePhase} view`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{activeSchools.map((school, index) => (
|
{activeSchools.map((school, index) => (
|
||||||
@@ -390,7 +396,13 @@ export function ComparisonView({
|
|||||||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||||||
</a>
|
</a>
|
||||||
<span className={styles.chipMeta}>
|
<span className={styles.chipMeta}>
|
||||||
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
{[
|
||||||
|
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
|
||||||
|
school.local_authority,
|
||||||
|
school.school_type,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -422,7 +434,11 @@ export function ComparisonView({
|
|||||||
benchmarks={benchmarks}
|
benchmarks={benchmarks}
|
||||||
isSecondary={!isPrimary}
|
isSecondary={!isPrimary}
|
||||||
/>
|
/>
|
||||||
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
<CompareAdmissions
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
isSecondary={!isPrimary}
|
||||||
|
/>
|
||||||
<CompareCommunity
|
<CompareCommunity
|
||||||
schools={activeSchools}
|
schools={activeSchools}
|
||||||
data={activeComparisonData}
|
data={activeComparisonData}
|
||||||
|
|||||||
@@ -123,7 +123,13 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 95vh;
|
/* Bottom sheet sized against the overlay (which tracks the visual
|
||||||
|
viewport), NOT vh: when the keyboard is open the overlay is short, so
|
||||||
|
max-height:100% keeps the whole sheet — input and results — above the
|
||||||
|
keyboard. min-height gives a comfortable default without a tiny stub,
|
||||||
|
but is capped at 100% so it never exceeds the visible area. */
|
||||||
|
min-height: min(55vh, 100%);
|
||||||
|
max-height: 100%;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import styles from './Modal.module.css';
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
}, [isOpen, handleEscape]);
|
}, [isOpen, handleEscape]);
|
||||||
|
|
||||||
|
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
|
||||||
|
// the on-screen keyboard shrinks the visual viewport but not the layout one,
|
||||||
|
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
|
||||||
|
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
|
||||||
|
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
|
||||||
|
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
|
const el = overlayRef.current;
|
||||||
|
if (!vv || !el) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
el.style.top = `${vv.offsetTop}px`;
|
||||||
|
el.style.height = `${vv.height}px`;
|
||||||
|
el.style.bottom = 'auto';
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
vv.addEventListener('resize', sync);
|
||||||
|
vv.addEventListener('scroll', sync);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', sync);
|
||||||
|
vv.removeEventListener('scroll', sync);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen || typeof window === 'undefined') return null;
|
if (!isOpen || typeof window === 'undefined') return null;
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
||||||
<div className={`${styles.modal} ${styles[size]}`}>
|
<div className={`${styles.modal} ${styles[size]}`}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{title && <h2 className={styles.title}>{title}</h2>}
|
{title && <h2 className={styles.title}>{title}</h2>}
|
||||||
|
|||||||
@@ -269,9 +269,24 @@ export function SchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// A report card is dated by its own inspection (rc_inspection_date); the
|
||||||
|
// legacy inspection_date belongs to an older inspection and must never
|
||||||
|
// date a report card (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAllSameGrade = (() => {
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
if (!ofsted || isReportCard) return false;
|
||||||
const subs = [
|
const subs = [
|
||||||
ofsted.quality_of_education,
|
ofsted.quality_of_education,
|
||||||
ofsted.behaviour_attitudes,
|
ofsted.behaviour_attitudes,
|
||||||
@@ -507,10 +522,10 @@ export function SchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -525,7 +540,7 @@ export function SchoolDetailView({
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
/* ── New Report Card layout ── */
|
/* ── New Report Card layout ── */
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
|
|||||||
@@ -2,14 +2,6 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
border: 1px solid var(--accent-gold, #c9a227);
|
border: 1px solid var(--accent-gold, #c9a227);
|
||||||
@@ -119,12 +111,16 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -159,21 +155,30 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
/* One scroll container on mobile: the modal content itself scrolls, so the
|
||||||
font-size: 1.25rem;
|
results list must not add its own inner scroll (double scrollbars, and
|
||||||
|
the input would be trapped above a short 400px window when the keyboard
|
||||||
|
shrinks the sheet). */
|
||||||
|
.results {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact stacked card: name + meta, then a full-width action so the tap
|
||||||
|
target is obvious and the card doesn't waste vertical space. */
|
||||||
.resultItem {
|
.resultItem {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.resultButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
<h2 className={styles.title}>Add School to Comparison</h2>
|
|
||||||
|
|
||||||
{!canAddMore && (
|
{!canAddMore && (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>
|
||||||
Maximum 5 schools can be compared. Remove a school to add another.
|
Maximum 5 schools can be compared. Remove a school to add another.
|
||||||
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAddSchool(school)}
|
onClick={() => handleAddSchool(school)}
|
||||||
disabled={alreadySelected || !canAddMore}
|
disabled={alreadySelected || !canAddMore}
|
||||||
className={
|
className={`${styles.resultButton} ${
|
||||||
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,9 +186,23 @@ export function SecondarySchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||||
|
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAllSameGrade = (() => {
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
if (!ofsted || isReportCard) return false;
|
||||||
const subs = [
|
const subs = [
|
||||||
ofsted.quality_of_education,
|
ofsted.quality_of_education,
|
||||||
ofsted.behaviour_attitudes,
|
ofsted.behaviour_attitudes,
|
||||||
@@ -332,10 +346,10 @@ export function SecondarySchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -349,7 +363,7 @@ export function SecondarySchoolDetailView({
|
|||||||
Ofsted reports ↗
|
Ofsted reports ↗
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
|
|||||||
@@ -148,9 +148,35 @@ export function CompareAcademics({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
|
||||||
|
// sat no KS2 tests (COVID), so there is no baseline to measure progress
|
||||||
|
// from. A bare "No data" reads as a gap on our side — say why. Judged
|
||||||
|
// PER SCHOOL on its own latest data year: a school whose data simply
|
||||||
|
// stops earlier (an unrelated gap) must not borrow the COVID explanation
|
||||||
|
// from a neighbour that does have 2024/25 data.
|
||||||
|
const p8NotPublished = urns.map((urn) => {
|
||||||
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
|
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||||
|
return y >= 202425;
|
||||||
|
});
|
||||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
||||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
||||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||||
|
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||||
|
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||||
|
|
||||||
|
// Every headline number gets its England anchor + verdict chip, so the
|
||||||
|
// "anchored against the England average" promise holds for the grade-5
|
||||||
|
// and EBacc rows too, not just Attainment 8.
|
||||||
|
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
|
||||||
|
if (value == null || anchor == null) return null;
|
||||||
|
const v = verdict(value, anchor, tol);
|
||||||
|
return (
|
||||||
|
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||||||
|
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -163,7 +189,8 @@ export function CompareAcademics({
|
|||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{att8[i] != null ? (
|
{att8[i] != null ? (
|
||||||
<>
|
<>
|
||||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
|
||||||
|
{anchorChip(att8[i], att8Anchor, 2)}
|
||||||
{att8Anchor != null && (
|
{att8Anchor != null && (
|
||||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -189,6 +216,11 @@ export function CompareAcademics({
|
|||||||
>
|
>
|
||||||
{banding[i]}
|
{banding[i]}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
) : p8NotPublished[i] ? (
|
||||||
|
<span className={s.small}>
|
||||||
|
Not published — this GCSE year group sat no KS2 tests (COVID), so DfE has no
|
||||||
|
baseline to measure progress from
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No data</span>
|
<span className={s.small}>No data</span>
|
||||||
)}
|
)}
|
||||||
@@ -200,14 +232,38 @@ export function CompareAcademics({
|
|||||||
</RowLabel>
|
</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
|
{grade5[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(grade5[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||||||
|
{grade5Anchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
|
{ebacc[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(ebacc[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||||||
|
{ebaccAnchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
</SectionGrid>
|
</SectionGrid>
|
||||||
@@ -218,6 +274,24 @@ export function CompareAcademics({
|
|||||||
const national = nationalAverages?.primary;
|
const national = nationalAverages?.primary;
|
||||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
||||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||||
|
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||||
|
// groups where single pupils move the percentage — show roughly how many
|
||||||
|
// pupils the figure rests on. Taken from the SAME yearly row that supplies
|
||||||
|
// the displayed percentage: resolving eligible_pupils and the
|
||||||
|
// disadvantaged share independently could mix years and misstate the
|
||||||
|
// cohort behind the figure.
|
||||||
|
const cohorts = urns.map((urn) => {
|
||||||
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (row.rwm_expected_disadvantaged_pct != null) {
|
||||||
|
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
|
||||||
|
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
|
||||||
|
return cohort > 0 ? cohort : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -270,6 +344,9 @@ export function CompareAcademics({
|
|||||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
{Math.round(value)}%
|
{Math.round(value)}%
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
|
{cohorts[i] != null && (
|
||||||
|
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
|
||||||
|
)}{' '}
|
||||||
{disadvantagedAnchor != null && (
|
{disadvantagedAnchor != null && (
|
||||||
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
||||||
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import type { ComparisonData, School } from '@/lib/types';
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
import { CHART_COLORS } from '@/lib/utils';
|
import { CHART_COLORS } from '@/lib/utils';
|
||||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -15,11 +15,17 @@ import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from '.
|
|||||||
export function CompareAdmissions({
|
export function CompareAdmissions({
|
||||||
schools,
|
schools,
|
||||||
data,
|
data,
|
||||||
|
isSecondary = false,
|
||||||
}: {
|
}: {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
|
isSecondary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
|
// Admissions rounds are phase-specific: an all-through school's Year 7
|
||||||
|
// round must never stand in for Reception on the primary tab (and vice
|
||||||
|
// versa) — beside pure primaries it reads as Reception odds.
|
||||||
|
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
|
||||||
|
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
|
||||||
const anyData = rows.some(Boolean);
|
const anyData = rows.some(Boolean);
|
||||||
const entryYear = rows.find(Boolean)?.year;
|
const entryYear = rows.find(Boolean)?.year;
|
||||||
const entryLabel = entryYear
|
const entryLabel = entryYear
|
||||||
@@ -28,7 +34,10 @@ export function CompareAdmissions({
|
|||||||
|
|
||||||
if (!anyData) {
|
if (!anyData) {
|
||||||
return (
|
return (
|
||||||
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
|
<Section
|
||||||
|
title="Getting a place"
|
||||||
|
how={`No ${roundLabel} admissions data is available for these schools yet.`}
|
||||||
|
>
|
||||||
<></>
|
<></>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
@@ -63,7 +72,9 @@ export function CompareAdmissions({
|
|||||||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No data</span>
|
<span className={s.small}>
|
||||||
|
We don't hold {roundLabel} admissions data for this school
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
@@ -104,15 +115,28 @@ export function CompareAdmissions({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const a = rows[i];
|
const a = rows[i];
|
||||||
const summary = summariseAdmissions(a);
|
const summary = summariseAdmissions(a);
|
||||||
|
const info = data[String(school.urn)]?.school_info;
|
||||||
|
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
|
||||||
|
const faith =
|
||||||
|
!!info?.religious_denomination &&
|
||||||
|
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
|
||||||
let text: string | null = null;
|
let text: string | null = null;
|
||||||
if (summary.firstPrefPct != null) {
|
if (summary.firstPrefPct != null) {
|
||||||
if (summary.firstPrefPct >= 100) {
|
if (selective) {
|
||||||
|
// Selective schools: the entrance test decides, whatever the
|
||||||
|
// offer percentage looks like — never the distance template.
|
||||||
|
text =
|
||||||
|
'Entry is by entrance test — the school is selective; distance and preference rank don’t decide places.';
|
||||||
|
} else if (summary.firstPrefPct >= 100) {
|
||||||
text = `Every family who put ${school.school_name} first got a place.`;
|
text = `Every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (summary.firstPrefPct >= 90) {
|
} else if (summary.firstPrefPct >= 90) {
|
||||||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (a?.oversubscribed) {
|
} else if (a?.oversubscribed) {
|
||||||
text =
|
text = isSecondary
|
||||||
'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
? faith
|
||||||
|
? 'More first-choice applications than places — check the school’s admission criteria (faith-based criteria may apply).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (catchment or distance often decides, but criteria vary).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||||||
} else {
|
} else {
|
||||||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
admissionsForPhase,
|
||||||
latestValues,
|
latestValues,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
summariseAdmissions,
|
summariseAdmissions,
|
||||||
@@ -145,7 +146,11 @@ export function CompareAtAGlance({
|
|||||||
|
|
||||||
<Measure label="Getting a place">
|
<Measure label="Getting a place">
|
||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
|
// Phase-matched round only — an all-through school's Year 7 round
|
||||||
|
// must not masquerade as Reception odds on the primary tab.
|
||||||
|
const summary = summariseAdmissions(
|
||||||
|
admissionsForPhase(data[String(school.urn)], isSecondary),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{summary.chip ? (
|
{summary.chip ? (
|
||||||
@@ -165,8 +170,14 @@ export function CompareAtAGlance({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const census = data[String(school.urn)]?.census;
|
const census = data[String(school.urn)]?.census;
|
||||||
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
||||||
|
// An all-through school's roll covers every age group, so judging
|
||||||
|
// it against the single-phase median ("Much larger than average")
|
||||||
|
// is meaningless — label the roll honestly instead.
|
||||||
|
const isAllThrough = /all.?through/i.test(school.phase ?? '');
|
||||||
let sizeNote: string | null = null;
|
let sizeNote: string | null = null;
|
||||||
if (pupils != null && medianPupils != null) {
|
if (isAllThrough) {
|
||||||
|
sizeNote = 'Whole-school roll (all-through, all ages)';
|
||||||
|
} else if (pupils != null && medianPupils != null) {
|
||||||
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
|
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
|
||||||
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
|
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
|
||||||
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
|
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
|
||||||
|
|||||||
@@ -48,10 +48,25 @@ export function CompareCommunity({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
title="Who goes there"
|
title="Who goes there"
|
||||||
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
|
how={
|
||||||
|
<>
|
||||||
|
The school's community, from the latest school census. State-school averages are
|
||||||
|
computed from our dataset and shown for context — there's no “right”
|
||||||
|
number here.
|
||||||
|
{anyAllThrough && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
For all-through schools these figures cover the whole school, all ages — not just
|
||||||
|
the {isSecondary ? 'secondary' : 'primary'} phase.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SectionGrid schools={schools}>
|
<SectionGrid schools={schools}>
|
||||||
<Measure label="Pupils on roll">
|
<Measure label="Pupils on roll">
|
||||||
|
|||||||
@@ -108,14 +108,21 @@ function JudgementDetailCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyAreas: Array<[string, number | null]> = [
|
const legacyAreas: Array<[string, number | null | undefined]> = [
|
||||||
['Quality of education', ofsted.quality_of_education],
|
['Quality of education', ofsted.quality_of_education],
|
||||||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||||||
['Personal development', ofsted.personal_development],
|
['Personal development', ofsted.personal_development],
|
||||||
['Leadership & management', ofsted.leadership_management],
|
['Leadership & management', ofsted.leadership_management],
|
||||||
['Early years provision', ofsted.early_years_provision],
|
['Early years provision', ofsted.early_years_provision],
|
||||||
|
['Sixth form provision', ofsted.sixth_form_provision],
|
||||||
];
|
];
|
||||||
const published = legacyAreas.filter(([, grade]) => grade != null);
|
// Only real Ofsted grades (1–4) are judgements. The MI file uses sentinel
|
||||||
|
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
|
||||||
|
// must never render as a rating chip.
|
||||||
|
const published = legacyAreas.filter(
|
||||||
|
(entry): entry is [string, number] =>
|
||||||
|
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
|
||||||
|
);
|
||||||
|
|
||||||
if (published.length === 0) {
|
if (published.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -232,8 +239,17 @@ export function CompareOfsted({
|
|||||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
{/* Short visible label to save space on mobile (the coloured
|
||||||
{school.school_name}'s Ofsted page →
|
school name already leads the row); the full name stays in
|
||||||
|
aria-label so screen readers can tell the links apart. */}
|
||||||
|
<a
|
||||||
|
className={s.link}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${school.school_name}'s Ofsted page`}
|
||||||
|
>
|
||||||
|
Ofsted page →
|
||||||
</a>
|
</a>
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,18 +60,10 @@
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
|
/* ComparisonChart owns its own canvas height now (a definite px value per
|
||||||
its container's height — which must be *definite*. A min-height alone does
|
breakpoint), with the mobile chip legend above and the gap note below it
|
||||||
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
|
flowing at natural size. This box therefore only needs to not constrain
|
||||||
to its ~150px default (a squashed sliver). Give it a real height. */
|
that height — no fixed height, or the note would again eat the plot. */
|
||||||
.chartBox {
|
.chartBox {
|
||||||
height: 420px;
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
/* Taller on mobile: the mobile-only school chips sit above the canvas and
|
|
||||||
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
|
|
||||||
.chartBox {
|
|
||||||
height: 360px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function TrendsExplorer({
|
|||||||
metric={metric}
|
metric={metric}
|
||||||
metricLabel={metricLabel}
|
metricLabel={metricLabel}
|
||||||
nationalByYear={nationalByYear}
|
nationalByYear={nationalByYear}
|
||||||
|
isSecondary={!isPrimaryPhase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,15 +72,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
font-size: 0.8rem;
|
/* Slightly larger than the values below it so the school each row belongs
|
||||||
|
to is easy to read on mobile (hidden on desktop, where the column header
|
||||||
|
names the school). */
|
||||||
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--sc, var(--text-secondary));
|
color: var(--sc, var(--text-secondary));
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cellDot {
|
.cellDot {
|
||||||
width: 9px;
|
width: 10px;
|
||||||
height: 9px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dot, var(--text-muted));
|
background: var(--dot, var(--text-muted));
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export interface CompareChart {
|
|||||||
/** True when England published a 2021/22 figure but no school has one —
|
/** True when England published a 2021/22 figure but no school has one —
|
||||||
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
||||||
showUnpublished202122Note: boolean;
|
showUnpublished202122Note: boolean;
|
||||||
|
/** Years where the England overlay has a value but no school does — the
|
||||||
|
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
|
||||||
|
* KS4 have different honest explanations, so the component owns the copy). */
|
||||||
|
englandOnlyYears: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCompareChart(
|
export function buildCompareChart(
|
||||||
@@ -92,11 +96,12 @@ export function buildCompareChart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx202122 = years.indexOf(202122);
|
const englandOnlyYears = years.filter(
|
||||||
const showUnpublished202122Note =
|
(year, i) =>
|
||||||
idx202122 >= 0 &&
|
englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
|
||||||
englandDataset?.data[idx202122] != null &&
|
);
|
||||||
schoolDatasets.every((ds) => ds.data[idx202122] == null);
|
|
||||||
|
|
||||||
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
|
const showUnpublished202122Note = englandOnlyYears.includes(202122);
|
||||||
|
|
||||||
|
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,41 @@ export interface AdmissionsSummary {
|
|||||||
interest: string | null;
|
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(
|
export function summariseAdmissions(
|
||||||
a: SchoolAdmissions | null | undefined,
|
a: SchoolAdmissions | null | undefined,
|
||||||
): AdmissionsSummary {
|
): AdmissionsSummary {
|
||||||
@@ -157,6 +192,12 @@ export function summariseAdmissions(
|
|||||||
if (pct != null) {
|
if (pct != null) {
|
||||||
if (pct >= 100) {
|
if (pct >= 100) {
|
||||||
chip = { tone: 'good', text: 'All first choices offered' };
|
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) {
|
} else if (pct < 75) {
|
||||||
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface OfstedInspection {
|
|||||||
quality_of_education: number | null;
|
quality_of_education: number | null;
|
||||||
behaviour_attitudes: number | null;
|
behaviour_attitudes: number | null;
|
||||||
personal_development: number | null;
|
personal_development: number | null;
|
||||||
|
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
|
||||||
|
sixth_form_provision?: number | null;
|
||||||
leadership_management: number | null;
|
leadership_management: number | null;
|
||||||
early_years_provision: number | null;
|
early_years_provision: number | null;
|
||||||
previous_overall: number | null;
|
previous_overall: number | null;
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ with DAG(
|
|||||||
|
|
||||||
dbt_build_ees = BashOperator(
|
dbt_build_ees = BashOperator(
|
||||||
task_id="dbt_build",
|
task_id="dbt_build",
|
||||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_typesense_ees = BashOperator(
|
sync_typesense_ees = BashOperator(
|
||||||
|
|||||||
Reference in New Issue
Block a user