Merge branch 'main' into fix/gias-blank-name-codes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 33s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 33s
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { CompareOfsted } from '@/components/compare/CompareOfsted';
|
||||
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return { urn, school_name: name } as School;
|
||||
}
|
||||
|
||||
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||
return {
|
||||
framework: null,
|
||||
inspection_date: '2021-10-07',
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
|
||||
|
||||
const data: Record<string, ComparisonData> = {
|
||||
'1': {
|
||||
school_info: schools[0],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
||||
},
|
||||
'2': {
|
||||
school_info: schools[1],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
||||
},
|
||||
'3': {
|
||||
school_info: schools[2],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({
|
||||
inspection_date: '2025-11-14',
|
||||
rc_safeguarding_met: true,
|
||||
report_card: {
|
||||
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
describe('CompareOfsted', () => {
|
||||
it('renders the three regimes without inventing an overall grade for report cards', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
|
||||
expect(screen.getByText('Outstanding')).toBeInTheDocument();
|
||||
// Carried-forward grade is shown but marked as such
|
||||
expect(screen.getByText('Good')).toBeInTheDocument();
|
||||
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
|
||||
// Report card: label present, no overall-grade badge for that school
|
||||
expect(screen.getByText('Report card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses one chip-list grammar for both regimes in judgement detail', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
// report-card area chip
|
||||
expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs attention')).toBeInTheDocument();
|
||||
// graded school without published subgrades → honest dataset statement
|
||||
expect(
|
||||
screen.getAllByText(/We don't hold area-by-area detail/i).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows the mixed-regime comparability note only when regimes differ', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links every school to its Ofsted page', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
const links = screen.getAllByRole('link', { name: /Ofsted page/i });
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||
});
|
||||
|
||||
it('renders a per-measure mobile tag with the short school name', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
// Each measure repeats the schools, so the short name ("Graded" from
|
||||
// "Graded School") appears once per measure (4) via the cell tag.
|
||||
expect(screen.getAllByText('Graded').length).toBe(4);
|
||||
expect(screen.getAllByText('Card').length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Regression: on refresh, the compare page must show the SSR-rendered data.
|
||||
*
|
||||
* The basket hydrates from the URL a beat after mount (selectedSchools is
|
||||
* empty for the first render), so the fetch effect must not blank the
|
||||
* SSR payload during that window — and must not refetch data the server
|
||||
* already provided.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
|
||||
const fetchComparison = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||
}));
|
||||
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return {
|
||||
urn,
|
||||
school_name: name,
|
||||
local_authority: 'Testshire',
|
||||
school_type: 'Community school',
|
||||
rwm_expected_pct: 80,
|
||||
phase: 'Primary',
|
||||
} as School;
|
||||
}
|
||||
|
||||
function data(urn: number, name: string): ComparisonData {
|
||||
return {
|
||||
school_info: school(urn, name),
|
||||
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_DATA = {
|
||||
'100': data(100, 'Alpha Primary'),
|
||||
'200': data(200, 'Beta Primary'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchComparison.mockReset();
|
||||
});
|
||||
|
||||
test('renders SSR data on refresh without wiping it or refetching', async () => {
|
||||
render(
|
||||
<ComparisonProvider>
|
||||
<ComparisonView
|
||||
initialData={INITIAL_DATA}
|
||||
initialNationalAverages={{
|
||||
year: 202425,
|
||||
primary: { rwm_expected_pct: 62 },
|
||||
secondary: {},
|
||||
by_year: [],
|
||||
}}
|
||||
initialBenchmarks={undefined}
|
||||
initialUrns={[100, 200]}
|
||||
metrics={[]}
|
||||
selectedMetric="rwm_expected_pct"
|
||||
/>
|
||||
</ComparisonProvider>,
|
||||
);
|
||||
|
||||
// Both SSR-provided schools appear (data was not blanked during hydration)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
|
||||
// …and the client never refetched data the server already rendered.
|
||||
expect(fetchComparison).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { DotStrip } from '@/components/DotStrip';
|
||||
|
||||
describe('DotStrip', () => {
|
||||
it('enumerates anchor and school values in the aria-label', () => {
|
||||
render(
|
||||
<DotStrip
|
||||
label="Reading"
|
||||
values={[91, 92, 87]}
|
||||
schoolNames={['Barclay', 'Elmhurst', 'Plumcroft']}
|
||||
anchor={{ value: 75, label: 'England 75%' }}
|
||||
/>,
|
||||
);
|
||||
const strip = screen.getByRole('img');
|
||||
expect(strip).toHaveAccessibleName(
|
||||
'Reading: England 75%, Barclay 91%, Elmhurst 92%, Plumcroft 87%',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the anchor tick when provided and not otherwise', () => {
|
||||
const { rerender } = render(
|
||||
<DotStrip
|
||||
label="Reading"
|
||||
values={[91]}
|
||||
schoolNames={['Barclay']}
|
||||
anchor={{ value: 75, label: 'England 75%' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('England 75%')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DotStrip label="Science" values={[95]} schoolNames={['Barclay']} anchor={null} />,
|
||||
);
|
||||
expect(screen.queryByText(/England/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips schools without a value', () => {
|
||||
render(
|
||||
<DotStrip
|
||||
label="Maths"
|
||||
values={[91, null]}
|
||||
schoolNames={['Barclay', 'Elmhurst']}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName('Maths: Barclay 91%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* buildCompareChart: every selected school must produce a rendered series
|
||||
* (regression guard for the production bug where a third school's line
|
||||
* vanished), the x-axis must include cancelled/unpublished years as real
|
||||
* gaps (never compressing time), and the England overlay renders dashed
|
||||
* with no gap-bridging.
|
||||
*/
|
||||
|
||||
import { buildCompareChart, fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
|
||||
function school(urn: number, years: Array<[number, number | null]>): ComparisonData {
|
||||
return {
|
||||
school_info: { urn, school_name: `School ${urn}` } as ComparisonData['school_info'],
|
||||
yearly_data: years.map(([year, v]) => ({ year, rwm_expected_pct: v })) as ComparisonData['yearly_data'],
|
||||
};
|
||||
}
|
||||
|
||||
const THREE_SCHOOLS = {
|
||||
'1': school(1, [[201819, 87], [202223, 87], [202425, 87]]),
|
||||
'2': school(2, [[201819, 88], [202223, 88], [202425, 92]]),
|
||||
'3': school(3, [[201819, 69], [202223, 62], [202425, 79]]),
|
||||
};
|
||||
|
||||
const SCHOOL_LIST = [1, 2, 3].map((urn) => ({ urn, school_name: `School ${urn}` }));
|
||||
|
||||
describe('fillAcademicYears', () => {
|
||||
it('fills every academic year between min and max', () => {
|
||||
expect(fillAcademicYears([201819, 202223])).toEqual([
|
||||
201819, 201920, 202021, 202122, 202223,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCompareChart', () => {
|
||||
it('renders one series per selected school — none silently dropped', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(chart.schoolDatasets).toHaveLength(3);
|
||||
for (const ds of chart.schoolDatasets) {
|
||||
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles float years from the API (202425.0 style)', () => {
|
||||
const floaty = {
|
||||
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||
};
|
||||
const chart = buildCompareChart(floaty, [SCHOOL_LIST[0]], 'rwm_expected_pct');
|
||||
expect(chart.schoolDatasets[0].data.filter((v) => v != null)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes cancelled/unpublished years as null gaps, not compressed time', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(chart.years).toContain(201920);
|
||||
expect(chart.years).toContain(202122);
|
||||
const idx = chart.years.indexOf(202021);
|
||||
expect(chart.schoolDatasets[0].data[idx]).toBeNull();
|
||||
});
|
||||
|
||||
it('adds a dashed England overlay when national data is supplied', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||
201819: 64.9,
|
||||
202122: 58.7,
|
||||
202223: 59.5,
|
||||
202425: 62.1,
|
||||
});
|
||||
expect(chart.englandDataset).not.toBeNull();
|
||||
const eng = chart.englandDataset!;
|
||||
expect(eng.label).toBe('England average');
|
||||
expect(eng.borderDash).toEqual([5, 4]);
|
||||
expect(eng.spanGaps).toBe(false);
|
||||
// England has a value for 2021/22 even though schools do not
|
||||
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
||||
});
|
||||
|
||||
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', {
|
||||
202122: 58.7,
|
||||
});
|
||||
expect(withNational.showUnpublished202122Note).toBe(true);
|
||||
const withoutNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(withoutNational.showUnpublished202122Note).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* compareLogic encodes the expert-reviewed comprehension rules for the
|
||||
* compare screen: report-card summarisation (safeguarding never counted),
|
||||
* three-regime Ofsted display, one consistent admissions chip metric,
|
||||
* CI-based progress banding, verdict chips and dot-strip geometry.
|
||||
*/
|
||||
|
||||
import {
|
||||
OFSTED_LEGACY_GRADES,
|
||||
ofstedDisplay,
|
||||
progressBand,
|
||||
rcAreaLabel,
|
||||
stripPositions,
|
||||
summariseAdmissions,
|
||||
summariseReportCard,
|
||||
verdict,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
|
||||
|
||||
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||
return {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const REPORT_CARD = {
|
||||
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||
rc_curriculum_teaching: { code: 2, label: 'Strong standard' },
|
||||
rc_personal_development: { code: 2, label: 'Strong standard' },
|
||||
rc_leadership_governance: { code: 2, label: 'Strong standard' },
|
||||
rc_inclusion: { code: 3, label: 'Expected standard' },
|
||||
rc_early_years: { code: 3, label: 'Expected standard' },
|
||||
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||
};
|
||||
|
||||
describe('summariseReportCard', () => {
|
||||
it('counts graded areas best-first and NAMES problem areas', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }),
|
||||
);
|
||||
expect(s.counts).toEqual([
|
||||
{ label: 'Strong standard', count: 4 },
|
||||
{ label: 'Expected standard', count: 2 },
|
||||
]);
|
||||
expect(s.problems).toEqual([
|
||||
{ areaLabel: 'Attendance & behaviour', label: 'Needs attention' },
|
||||
]);
|
||||
expect(s.safeguarding).toBe('met');
|
||||
expect(s.allClear).toBe(false);
|
||||
});
|
||||
|
||||
it('never counts safeguarding as a graded area', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({
|
||||
report_card: { rc_achievement: { code: 3, label: 'Expected standard' } },
|
||||
rc_safeguarding_met: true,
|
||||
}),
|
||||
);
|
||||
const total = s.counts.reduce((n, c) => n + c.count, 0);
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
it('is allClear when everything is Expected standard or better and safeguarding met', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({
|
||||
report_card: {
|
||||
rc_achievement: { code: 3, label: 'Expected standard' },
|
||||
rc_inclusion: { code: 1, label: 'Exceptional' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
}),
|
||||
);
|
||||
expect(s.allClear).toBe(true);
|
||||
expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 });
|
||||
});
|
||||
|
||||
it('passes labels through from the API — never invents wording', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }),
|
||||
);
|
||||
expect(JSON.stringify(s)).not.toContain('Attention needed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ofstedDisplay', () => {
|
||||
it('prefers the report card over any legacy grade', () => {
|
||||
const d = ofstedDisplay(
|
||||
ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }),
|
||||
);
|
||||
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('handles missing data', () => {
|
||||
expect(ofstedDisplay(null).kind).toBe('none');
|
||||
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('uses the four legacy grade words', () => {
|
||||
expect(OFSTED_LEGACY_GRADES).toEqual({
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Requires improvement',
|
||||
4: 'Inadequate',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rcAreaLabel', () => {
|
||||
it('maps rc keys to the mockups’ area labels', () => {
|
||||
expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour');
|
||||
expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching');
|
||||
expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summariseAdmissions', () => {
|
||||
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||
return {
|
||||
year: 202627,
|
||||
places_offered: null,
|
||||
total_applications: null,
|
||||
first_preference_offer_pct: null,
|
||||
oversubscribed: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('97% → good chip with the mockup wording', () => {
|
||||
const s = summariseAdmissions(
|
||||
admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }),
|
||||
);
|
||||
expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' });
|
||||
expect(s.interest).toBe('Named on 457 forms · 180 places');
|
||||
});
|
||||
|
||||
it('73% → warn chip "Over 1 in 4 first choices missed out"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 }));
|
||||
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
||||
});
|
||||
|
||||
it('100% → "All first choices offered"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
||||
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
||||
});
|
||||
|
||||
it('no data → null chip and interest', () => {
|
||||
const s = summariseAdmissions(null);
|
||||
expect(s.chip).toBeNull();
|
||||
expect(s.interest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressBand', () => {
|
||||
it('CI entirely above zero → above', () => {
|
||||
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||
});
|
||||
it('CI entirely below zero → below', () => {
|
||||
expect(progressBand(-1.2, -2.0, -0.4)).toBe('below');
|
||||
});
|
||||
it('CI straddling zero → average', () => {
|
||||
expect(progressBand(0.3, -0.5, 1.1)).toBe('average');
|
||||
});
|
||||
it('missing CI → null (no naive thresholding)', () => {
|
||||
expect(progressBand(1.2, null, null)).toBeNull();
|
||||
expect(progressBand(null, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict', () => {
|
||||
it('above / close / below with a 2pp tolerance', () => {
|
||||
expect(verdict(87, 62)).toBe('above');
|
||||
expect(verdict(61, 62)).toBe('close');
|
||||
expect(verdict(40, 62)).toBe('below');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPositions', () => {
|
||||
it('maps a custom domain', () => {
|
||||
const pts = stripPositions([106], 100, 120);
|
||||
expect(pts[0].pos).toBe(30);
|
||||
});
|
||||
|
||||
it('flips a colliding label above', () => {
|
||||
const pts = stripPositions([91, 92], 0, 100);
|
||||
const sorted = [...pts].sort((a, b) => a.value - b.value);
|
||||
expect(sorted[0].labelAbove).toBe(false);
|
||||
expect(sorted[1].labelAbove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips nulls and keeps school indices', () => {
|
||||
const pts = stripPositions([50, null, 70], 0, 100);
|
||||
expect(pts).toHaveLength(2);
|
||||
expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('clamps out-of-domain values', () => {
|
||||
const pts = stripPositions([95], 100, 120);
|
||||
expect(pts[0].pos).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestValues', () => {
|
||||
const data = {
|
||||
'1': {
|
||||
yearly_data: [
|
||||
{ year: 202324, rwm_expected_pct: 75 },
|
||||
{ year: 202425, rwm_expected_pct: 87 },
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
yearly_data: [
|
||||
{ year: 202324, rwm_expected_pct: 82 },
|
||||
{ year: 202425, rwm_expected_pct: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('takes the latest non-null value per school in urn order', async () => {
|
||||
const { latestValues } = await import('@/lib/compareLogic');
|
||||
expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]);
|
||||
});
|
||||
|
||||
it('returns null for unknown schools and metrics', async () => {
|
||||
const { latestValues } = await import('@/lib/compareLogic');
|
||||
expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]);
|
||||
expect(latestValues(data, [1], 'nope')).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
debounce,
|
||||
buildOfstedListBadge,
|
||||
metricKind,
|
||||
shortName,
|
||||
computeYBounds,
|
||||
} from '@/lib/utils';
|
||||
|
||||
@@ -223,3 +224,17 @@ describe('isProposedToClose', () => {
|
||||
expect(isProposedToClose({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortName', () => {
|
||||
it('drops the trailing establishment-type words', () => {
|
||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||
expect(shortName('Elmhurst Primary School')).toBe('Elmhurst');
|
||||
expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's");
|
||||
expect(shortName('Riverside Community Junior School')).toBe('Riverside');
|
||||
});
|
||||
|
||||
it('keeps a name that carries no type suffix, capping very long ones', () => {
|
||||
expect(shortName('Beaver Road')).toBe('Beaver Road');
|
||||
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,10 @@ interface ComparePageProps {
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Compare Schools',
|
||||
description: 'Compare KS2 performance across multiple primary schools in England',
|
||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
||||
description:
|
||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
};
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
@@ -30,26 +32,24 @@ export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||
const selectedMetric = metricParam || 'rwm_expected_pct';
|
||||
|
||||
try {
|
||||
// Fetch comparison data if URNs provided
|
||||
let comparisonData = null;
|
||||
if (urns.length > 0) {
|
||||
try {
|
||||
const response = await fetchComparison(urnsParam!);
|
||||
comparisonData = response.comparison;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comparison:', error);
|
||||
}
|
||||
}
|
||||
// Fetch comparison + metrics in parallel — they are independent.
|
||||
const [comparisonResponse, metricsResponse] = await Promise.all([
|
||||
urns.length > 0
|
||||
? fetchComparison(urnsParam!).catch((error) => {
|
||||
console.error('Failed to fetch comparison:', error);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
fetchMetrics(),
|
||||
]);
|
||||
|
||||
// Fetch available metrics
|
||||
const metricsResponse = await fetchMetrics();
|
||||
|
||||
// Metrics is already an array
|
||||
const metricsArray = metricsResponse?.metrics || [];
|
||||
|
||||
return (
|
||||
<ComparisonView
|
||||
initialData={comparisonData}
|
||||
initialData={comparisonResponse?.comparison ?? null}
|
||||
initialNationalAverages={comparisonResponse?.national_averages}
|
||||
initialBenchmarks={comparisonResponse?.benchmarks}
|
||||
initialUrns={urns}
|
||||
metrics={metricsArray}
|
||||
selectedMetric={selectedMetric}
|
||||
|
||||
@@ -65,3 +65,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chartNote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
CHART_COLORS,
|
||||
@@ -34,13 +35,16 @@ interface ComparisonChartProps {
|
||||
schools: Array<{ urn: number; school_name: string }>;
|
||||
metric: string;
|
||||
metricLabel: string;
|
||||
/** Official England figure per academic year for this metric — renders a
|
||||
* dashed grey reference line when provided. */
|
||||
nationalByYear?: Record<number, number | null | undefined>;
|
||||
}
|
||||
|
||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||
// converging lines stay tellable apart without relying on hue alone.
|
||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
@@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
return <div>No data available</div>;
|
||||
}
|
||||
|
||||
// Union of years across all schools — coverage differs between them.
|
||||
const years = [
|
||||
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
|
||||
].sort((a, b) => a - b);
|
||||
// Pure, tested series construction: union of years with cancelled /
|
||||
// unpublished years kept as real gaps, plus the England overlay.
|
||||
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
||||
const { years } = built;
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
|
||||
const data = comparisonData[String(school.urn)];
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
label: school.school_name,
|
||||
data: years.map((year) => {
|
||||
const yearData = data?.yearly_data.find((d) => d.year === year);
|
||||
if (!yearData) return null;
|
||||
return yearData[metric as keyof typeof yearData] as number | null;
|
||||
}),
|
||||
label: series.label,
|
||||
data: series.data,
|
||||
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||||
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
||||
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
||||
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
|
||||
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
||||
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||||
pointHoverRadius: isMobile ? 5 : 6,
|
||||
tension: 0.3,
|
||||
spanGaps: true,
|
||||
// Never bridge missing years — gaps are information (COVID
|
||||
// cancellations, unpublished 2021/22, schools that opened later).
|
||||
spanGaps: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (built.englandDataset) {
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
pointStyle: 'line',
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0,
|
||||
spanGaps: false,
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: years.map(formatAcademicYear),
|
||||
datasets,
|
||||
@@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
<div className={styles.canvasBox}>
|
||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||
</div>
|
||||
{built.showUnpublished202122Note && (
|
||||
<p className={styles.chartNote}>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,15 @@
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
@@ -72,408 +79,136 @@
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
}
|
||||
|
||||
/* Metric Selector */
|
||||
.metricSelector {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
narrow screens. Offset by the sticky site header's height (Navigation is
|
||||
position: sticky, top: 0) so this bar pins just below it instead of
|
||||
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
|
||||
.schoolBar {
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricSelect {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.metricSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.metricSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.metricSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.metricSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Schools Section */
|
||||
.schoolsSection {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.schoolsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.schoolCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schoolCard:hover {
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.removeButton:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-right: 2rem;
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.schoolName a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolName a:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.schoolMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.latestValue {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
text-align: center;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
margin-bottom: -1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0 0 12px 9px;
|
||||
}
|
||||
|
||||
.latestLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.latestNumber {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* Chart Section */
|
||||
.chartSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 1em;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadingMessage {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Table Section */
|
||||
.tableSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Right-edge fade so phone users see the comparison table scrolls.
|
||||
Otherwise the wider-than-viewport table silently clips. */
|
||||
@media (max-width: 640px) {
|
||||
.tableWrapper {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.9375rem;
|
||||
.chipDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.comparisonTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
.chipText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comparisonTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
.chipName {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
.chipNameShort {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comparisonTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-align: left;
|
||||
background: var(--bg-card, white);
|
||||
}
|
||||
|
||||
/* Sticky first column (Year) so labels remain visible while scrolling */
|
||||
.comparisonTable th:first-child,
|
||||
.comparisonTable td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
|
||||
}
|
||||
|
||||
.comparisonTable thead th:first-child {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:hover td:first-child {
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.yearCell {
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.emptyStateDescription {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
max-width: 400px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.metricDescription {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.progressNote {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent-teal);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
/* Mobile: the sticky school bar becomes compact, horizontally-scrollable
|
||||
pills with short names (matching the mobile mockup) instead of full-width
|
||||
cards whose names wrap to several lines. */
|
||||
@media (max-width: 640px) {
|
||||
/* The mobile Navigation header is shorter (≈57px). */
|
||||
.schoolBar {
|
||||
top: 57px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.75rem;
|
||||
.schoolChip {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
border-top-width: 2px;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.metricSelector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
.chipName {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricSelect {
|
||||
max-width: 100%;
|
||||
.chipNameFull {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schoolsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
.chipNameShort {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chartSection,
|
||||
.tableSection {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
.chipMeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
/* Taller than desktop's proportion would suggest: the chip legend row
|
||||
sits inside, and the in-chart title/legend/axis titles are gone, so
|
||||
nearly all of this is plot area. */
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.comparisonTable th,
|
||||
.comparisonTable td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.latestValue {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0 0 8px 5px;
|
||||
.chipRemove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
/**
|
||||
* ComparisonView Component
|
||||
* Client-side comparison interface with phase tabs, charts, and tables
|
||||
* ComparisonView — the parent-first compare screen: a sticky school bar and
|
||||
* six sections (At a glance / Ofsted / Academics / Getting a place / Who
|
||||
* goes there / Explore trends), every number anchored against the England
|
||||
* average or the computed state-school benchmark with provenance-correct
|
||||
* labels. Layout and copy follow the reviewed mockups
|
||||
* (docs/superpowers/specs/mockups/).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
||||
import { CompareAtAGlance } from './compare/CompareAtAGlance';
|
||||
import { CompareOfsted } from './compare/CompareOfsted';
|
||||
import { CompareAcademics } from './compare/CompareAcademics';
|
||||
import { CompareAdmissions } from './compare/CompareAdmissions';
|
||||
import { CompareCommunity } from './compare/CompareCommunity';
|
||||
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
|
||||
import type {
|
||||
Benchmarks,
|
||||
ComparisonData,
|
||||
MetricDefinition,
|
||||
NationalAverages,
|
||||
School,
|
||||
} from '@/lib/types';
|
||||
import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface ComparisonViewProps {
|
||||
initialData: Record<string, ComparisonData> | null;
|
||||
initialNationalAverages?: NationalAverages;
|
||||
initialBenchmarks?: Benchmarks;
|
||||
initialUrns: number[];
|
||||
metrics: MetricDefinition[];
|
||||
selectedMetric: string;
|
||||
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
|
||||
|
||||
export function ComparisonView({
|
||||
initialData,
|
||||
initialNationalAverages,
|
||||
initialBenchmarks,
|
||||
initialUrns,
|
||||
metrics,
|
||||
selectedMetric: initialMetric,
|
||||
@@ -58,11 +53,15 @@ export function ComparisonView({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
|
||||
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [comparisonData, setComparisonData] = useState(initialData);
|
||||
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>(
|
||||
initialNationalAverages,
|
||||
);
|
||||
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(initialBenchmarks);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||
// Tracks whether the user has explicitly clicked a phase tab.
|
||||
@@ -77,24 +76,27 @@ export function ComparisonView({
|
||||
if (!isInitialized) return;
|
||||
if (initialUrns.length > 0 && initialData) {
|
||||
const urlSchools = initialUrns
|
||||
.map(urn => initialData[String(urn)]?.school_info)
|
||||
.map((urn) => initialData[String(urn)]?.school_info)
|
||||
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
||||
const sameSet =
|
||||
urlSchools.length === selectedSchools.length &&
|
||||
urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn));
|
||||
urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
|
||||
if (urlSchools.length > 0 && !sameSet) {
|
||||
replaceSchools(urlSchools);
|
||||
}
|
||||
}
|
||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync URL with selected schools
|
||||
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
||||
|
||||
// Sync the URL with the selection + metric. Pure navigation state — no
|
||||
// fetching here: metric changes are presentational (the data is already
|
||||
// client-side) and must not refire the comparison request.
|
||||
useEffect(() => {
|
||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (urns) {
|
||||
params.set('urns', urns);
|
||||
if (urnKey) {
|
||||
params.set('urns', urnKey);
|
||||
} else {
|
||||
params.delete('urns');
|
||||
}
|
||||
@@ -103,52 +105,70 @@ export function ComparisonView({
|
||||
|
||||
const newUrl = `${pathname}?${params.toString()}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
||||
|
||||
// Fetch comparison data
|
||||
if (selectedSchools.length > 0) {
|
||||
fetchComparison(urns, { cache: 'no-store' })
|
||||
.then((data) => {
|
||||
setComparisonData(data.comparison);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Keep whatever we already have (SSR data or a previous fetch) rather
|
||||
// than blanking the chart — a transient refetch failure shouldn't
|
||||
// destroy a working comparison the user is looking at.
|
||||
console.error('Failed to fetch comparison:', err);
|
||||
});
|
||||
} else {
|
||||
setComparisonData(null);
|
||||
}
|
||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
||||
// Fetch when the school set changes, but only for schools we don't already
|
||||
// have data for. This skips the refetch of SSR-rendered data on load AND
|
||||
// avoids a network call when a school is merely removed. A ref holds the
|
||||
// latest data so the effect can read it without re-running on every fetch.
|
||||
//
|
||||
// Correctness note: we must NOT null the data on a transient empty urnKey.
|
||||
// On mount the basket is empty for a beat before it hydrates from the URL,
|
||||
// and blanking here (then skipping the refetch because SSR "covers" the set)
|
||||
// was leaving the page empty on refresh. The render already shows the empty
|
||||
// state whenever `selectedSchools` is empty, so stale data for deselected
|
||||
// schools is harmless — it's simply unused.
|
||||
const comparisonDataRef = useRef(comparisonData);
|
||||
comparisonDataRef.current = comparisonData;
|
||||
|
||||
// Classify schools by phase using comparison data
|
||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !urnKey) return;
|
||||
|
||||
const have = comparisonDataRef.current ?? {};
|
||||
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||
if (covered) return;
|
||||
|
||||
fetchComparison(urnKey, { cache: 'no-store' })
|
||||
.then((data) => {
|
||||
setComparisonData(data.comparison);
|
||||
setNationalAverages(data.national_averages);
|
||||
setBenchmarks(data.benchmarks);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Keep whatever we already have (SSR data or a previous fetch) rather
|
||||
// than blanking the page — a transient refetch failure shouldn't
|
||||
// destroy a working comparison the user is looking at.
|
||||
console.error('Failed to fetch comparison:', err);
|
||||
});
|
||||
}, [urnKey, isInitialized]);
|
||||
|
||||
const primarySchools = selectedSchools.filter((school) => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
if (info?.attainment_8_score != null) return 'secondary';
|
||||
if (info?.rwm_expected_pct != null) return 'primary';
|
||||
// Fallback: check yearly data
|
||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
||||
return 'primary';
|
||||
};
|
||||
const hasPrimaryData =
|
||||
info?.rwm_expected_pct != null ||
|
||||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
|
||||
if (hasPrimaryData) return true;
|
||||
return school.phase?.toLowerCase().includes('primary') || false;
|
||||
});
|
||||
|
||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||
const secondarySchools = selectedSchools.filter((school) => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
const hasSecondaryData =
|
||||
info?.attainment_8_score != null ||
|
||||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
|
||||
if (hasSecondaryData) return true;
|
||||
return school.phase?.toLowerCase().includes('secondary') || false;
|
||||
});
|
||||
|
||||
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
||||
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
||||
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
||||
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
||||
// Auto-select tab with more schools and sync the metric to match the phase.
|
||||
useEffect(() => {
|
||||
if (!comparisonData || selectedSchools.length === 0) return;
|
||||
if (phaseLockedByUser.current) return;
|
||||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||
setComparePhase(newPhase);
|
||||
// Only reset the metric when it doesn't belong to the newly detected phase.
|
||||
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
||||
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||
const metricFitsPhase = metrics.some(
|
||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
|
||||
);
|
||||
if (!metricFitsPhase) {
|
||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
@@ -158,29 +178,24 @@ export function ComparisonView({
|
||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||
phaseLockedByUser.current = true;
|
||||
setComparePhase(phase);
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
setSelectedMetric(defaultMetric);
|
||||
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
};
|
||||
|
||||
// compare_viewed: fire once after the page has its first selection.
|
||||
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
||||
// sent only when there's actual content to view, not for empty arrivals.
|
||||
const compareViewedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (compareViewedRef.current) return;
|
||||
if (selectedSchools.length === 0) return;
|
||||
compareViewedRef.current = true;
|
||||
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
|
||||
const primaryCount = selectedSchools.filter((s) =>
|
||||
s.phase?.toLowerCase().includes('primary'),
|
||||
).length;
|
||||
const secondaryCount = selectedSchools.length - primaryCount;
|
||||
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||
const phaseMix =
|
||||
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||
}, [selectedSchools]);
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
track('compare_metric_changed', { metric, phase: comparePhase });
|
||||
setSelectedMetric(metric);
|
||||
};
|
||||
|
||||
const handleRemoveSchool = (urn: number) => {
|
||||
removeSchool(urn);
|
||||
track('compare_school_removed', { urn, from: 'compare' });
|
||||
@@ -191,21 +206,22 @@ export function ComparisonView({
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
text: count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
url,
|
||||
};
|
||||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
||||
// canShare is feature-detected because Safari iOS exposes share() but
|
||||
// some configurations refuse the payload.
|
||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.share &&
|
||||
(!navigator.canShare || navigator.canShare(shareData))
|
||||
) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
track('compare_shared', { method: 'native', school_count: count });
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
@@ -214,27 +230,22 @@ export function ComparisonView({
|
||||
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||
setShareConfirm(true);
|
||||
setTimeout(() => setShareConfirm(false), 2000);
|
||||
} catch { /* fallback: do nothing */ }
|
||||
} catch {
|
||||
/* fallback: do nothing */
|
||||
}
|
||||
};
|
||||
|
||||
const isPrimary = comparePhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||
|
||||
// Get metric definition
|
||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||||
|
||||
// No schools selected
|
||||
if (selectedSchools.length === 0) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1>Compare Schools</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Add schools to your comparison basket to see side-by-side performance data
|
||||
Add schools to your comparison basket to see them side by side — inspection results,
|
||||
academics, admissions and community.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -252,39 +263,46 @@ export function ComparisonView({
|
||||
);
|
||||
}
|
||||
|
||||
// Build filtered comparison data for active phase
|
||||
// Build filtered comparison data for the active phase
|
||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||
if (comparisonData) {
|
||||
activeSchools.forEach(s => {
|
||||
activeSchools.forEach((s) => {
|
||||
if (comparisonData[s.urn]) {
|
||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get years for table
|
||||
const years =
|
||||
Object.keys(activeComparisonData).length > 0
|
||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
: [];
|
||||
const hasData = Object.keys(activeComparisonData).length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div>
|
||||
<h1>Compare Schools</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
|
||||
{selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
|
||||
— each number anchored against the England average so you can tell at a glance
|
||||
what's typical and what stands out.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className={styles.headerActions}>
|
||||
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
||||
+ Add School
|
||||
</button>
|
||||
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
{shareConfirm ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -292,20 +310,22 @@ export function ComparisonView({
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary ({primarySchools.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary ({secondarySchools.length})
|
||||
</button>
|
||||
</div>
|
||||
{secondarySchools.length > 0 && primarySchools.length > 0 && (
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary ({primarySchools.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary ({secondarySchools.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSchools.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -318,184 +338,86 @@ export function ComparisonView({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Metric Selector */}
|
||||
<section className={styles.metricSelector}>
|
||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||
Select Metric:
|
||||
</label>
|
||||
<select
|
||||
id="metric-select"
|
||||
value={selectedMetric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.metricSelect}
|
||||
>
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Progress score explanation */}
|
||||
{selectedMetric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* School Cards */}
|
||||
<section className={styles.schoolsSection}>
|
||||
<div className={styles.schoolsGrid}>
|
||||
{activeSchools.map((school, index) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={styles.schoolCard}
|
||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleRemoveSchool(school.urn)}
|
||||
className={styles.removeButton}
|
||||
aria-label={`Remove ${school.school_name}`}
|
||||
title="Remove from comparison"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 className={styles.schoolName}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||
</h2>
|
||||
<div className={styles.schoolMeta}>
|
||||
{school.local_authority && (
|
||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
||||
)}
|
||||
{school.school_type && (
|
||||
<span className={styles.metaItem}>{school.school_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Latest metric value */}
|
||||
{activeComparisonData[school.urn] && (
|
||||
<div className={styles.latestValue}>
|
||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
||||
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
|
||||
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
||||
marginRight: '0.4rem',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||
if (yearlyData.length === 0) return '-';
|
||||
|
||||
const latestData = yearlyData[yearlyData.length - 1];
|
||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
||||
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
if (selectedMetric.includes('progress')) {
|
||||
return formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
return formatPercentage(value as number);
|
||||
} else {
|
||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Chart */}
|
||||
{Object.keys(activeComparisonData).length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||
<div className={styles.chartContainer}>
|
||||
<ComparisonChart
|
||||
comparisonData={activeComparisonData}
|
||||
schools={activeSchools}
|
||||
metric={selectedMetric}
|
||||
metricLabel={metricLabel}
|
||||
{/* Sticky school bar — column identity while scrolling */}
|
||||
<div className={styles.schoolBar} aria-label="Schools in this comparison">
|
||||
{activeSchools.map((school, index) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={styles.schoolChip}
|
||||
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
>
|
||||
<span
|
||||
className={styles.chipDot}
|
||||
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={styles.chipText}>
|
||||
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
|
||||
<span className={styles.chipNameFull}>{school.school_name}</span>
|
||||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||||
</a>
|
||||
<span className={styles.chipMeta}>
|
||||
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveSchool(school.urn)}
|
||||
className={styles.chipRemove}
|
||||
aria-label={`Remove ${school.school_name}`}
|
||||
title="Remove from comparison"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : activeSchools.length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<LoadingSkeleton type="list" />
|
||||
</section>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||
<section className={styles.tableSection}>
|
||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.comparisonTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{activeSchools.map((school) => (
|
||||
<th key={school.urn}>{school.school_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||||
{activeSchools.map((school) => {
|
||||
const schoolData = activeComparisonData[school.urn];
|
||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||
{hasData && (
|
||||
<>
|
||||
<CompareAtAGlance
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
nationalAverages={nationalAverages}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||||
<CompareAcademics
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
nationalAverages={nationalAverages}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
||||
<CompareCommunity
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<TrendsExplorer
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
metrics={metrics}
|
||||
metric={selectedMetric}
|
||||
onMetricChange={setSelectedMetric}
|
||||
isPrimaryPhase={isPrimary}
|
||||
nationalAverages={nationalAverages}
|
||||
/>
|
||||
|
||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||
if (!yearData) return <td key={school.urn}>-</td>;
|
||||
|
||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <td key={school.urn}>-</td>;
|
||||
}
|
||||
|
||||
let displayValue: string;
|
||||
if (selectedMetric.includes('progress')) {
|
||||
displayValue = formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
displayValue = formatPercentage(value as number);
|
||||
} else {
|
||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||
}
|
||||
|
||||
return <td key={school.urn}>{displayValue}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p className={styles.footnote}>
|
||||
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
|
||||
outcomes, DfE school admissions data, school census. England averages for test
|
||||
results are official DfE figures; other benchmarks are state-school averages
|
||||
computed from our dataset. Following DfE practice, figures based on 5 or fewer
|
||||
pupils are suppressed and shown as "no data".
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* School Search Modal */}
|
||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
.row {
|
||||
margin: 1.1rem 0 1.6rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.headNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.strip {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 15px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.anchorTick {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 2px;
|
||||
height: 26px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.anchorLabel {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pointLabelAbove {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.row {
|
||||
margin: 0.9rem 0 1.3rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.strip {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.point {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* DotStrip — the compare screen's signature element: one measure per strip,
|
||||
* every school's dot on a shared track, anchored by a grey England-average
|
||||
* tick so "right of the tick = above average" needs no domain knowledge.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { stripPositions } from '@/lib/compareLogic';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
|
||||
import styles from './DotStrip.module.css';
|
||||
|
||||
export interface DotStripProps {
|
||||
label: string;
|
||||
/** One value per school; index = the school's chart-colour index. */
|
||||
values: Array<number | null>;
|
||||
schoolNames: string[];
|
||||
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
|
||||
* benchmark isn't available — the caller should say why in `headNote`. */
|
||||
anchor?: { value: number; label: string } | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
/** Tooltip on the measure label (plain-English definition). */
|
||||
tip?: string;
|
||||
/** Small note on the right of the header row (e.g. the tick legend). */
|
||||
headNote?: string;
|
||||
}
|
||||
|
||||
export function DotStrip({
|
||||
label,
|
||||
values,
|
||||
schoolNames,
|
||||
anchor = null,
|
||||
min = 0,
|
||||
max = 100,
|
||||
unit = '%',
|
||||
tip,
|
||||
headNote,
|
||||
}: DotStripProps) {
|
||||
const points = stripPositions(values, min, max);
|
||||
const span = max - min;
|
||||
const anchorPos =
|
||||
anchor != null
|
||||
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
|
||||
: null;
|
||||
|
||||
const ariaParts = [
|
||||
anchor ? `${anchor.label}` : null,
|
||||
...points.map(
|
||||
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title} title={tip}>
|
||||
{label}
|
||||
</span>
|
||||
{headNote && <span className={styles.headNote}>{headNote}</span>}
|
||||
</div>
|
||||
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
|
||||
<div className={styles.track} />
|
||||
{anchorPos != null && anchor && (
|
||||
<>
|
||||
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
|
||||
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
|
||||
{anchor.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{points.map((p) => (
|
||||
<span key={p.schoolIndex}>
|
||||
<span
|
||||
className={styles.point}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
|
||||
}}
|
||||
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
|
||||
}}
|
||||
>
|
||||
{p.value}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.moreMeasures {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.moreMeasures summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.stripNote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* How children do academically — tier-1 dot strips anchored on official
|
||||
* England averages, tier-2 "More measures" one tap away, equity row against
|
||||
* the computed state-school benchmark. Copy verbatim from the reviewed
|
||||
* mockups; teacher-assessed measures are labelled as such.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
import { DotStrip } from '@/components/DotStrip';
|
||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
import styles from './CompareAcademics.module.css';
|
||||
|
||||
interface StripSpec {
|
||||
label: string;
|
||||
metric: string;
|
||||
anchorKey?: string;
|
||||
tip?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const TIER1_PRIMARY: StripSpec[] = [
|
||||
{
|
||||
label: 'Reading, writing & maths — expected standard',
|
||||
metric: 'rwm_expected_pct',
|
||||
anchorKey: 'rwm_expected_pct',
|
||||
tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.',
|
||||
},
|
||||
{ label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' },
|
||||
{
|
||||
label: 'Writing (teacher-assessed)',
|
||||
metric: 'writing_expected_pct',
|
||||
anchorKey: 'writing_expected_pct',
|
||||
tip: 'Writing is assessed by teachers, not tested.',
|
||||
},
|
||||
{ label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' },
|
||||
{
|
||||
label: 'Working at a higher standard than expected',
|
||||
metric: 'rwm_high_pct',
|
||||
anchorKey: 'rwm_high_pct',
|
||||
tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.',
|
||||
},
|
||||
];
|
||||
|
||||
const TIER2_PRIMARY: StripSpec[] = [
|
||||
{
|
||||
label: 'Grammar, punctuation & spelling — expected standard',
|
||||
metric: 'gps_expected_pct',
|
||||
anchorKey: 'gps_expected_pct',
|
||||
},
|
||||
{
|
||||
label: 'Science — expected standard (teacher-assessed)',
|
||||
metric: 'science_expected_pct',
|
||||
anchorKey: 'science_expected_pct',
|
||||
tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — reading',
|
||||
metric: 'reading_avg_score',
|
||||
anchorKey: 'reading_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — maths',
|
||||
metric: 'maths_avg_score',
|
||||
anchorKey: 'maths_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — grammar, punctuation & spelling',
|
||||
metric: 'gps_avg_score',
|
||||
anchorKey: 'gps_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
];
|
||||
|
||||
function Strip({
|
||||
spec,
|
||||
data,
|
||||
urns,
|
||||
schoolNames,
|
||||
national,
|
||||
}: {
|
||||
spec: StripSpec;
|
||||
data: Record<string, ComparisonData>;
|
||||
urns: number[];
|
||||
schoolNames: string[];
|
||||
national: Record<string, number> | undefined;
|
||||
}) {
|
||||
const values = latestValues(data, urns, spec.metric).map((v) =>
|
||||
v != null ? Math.round(v) : null,
|
||||
);
|
||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||
const anchor =
|
||||
anchorValue != null
|
||||
? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` }
|
||||
: null;
|
||||
if (values.every((v) => v == null)) return null;
|
||||
return (
|
||||
<DotStrip
|
||||
label={spec.label}
|
||||
values={values}
|
||||
schoolNames={schoolNames}
|
||||
anchor={anchor}
|
||||
min={spec.min ?? 0}
|
||||
max={spec.max ?? 100}
|
||||
unit={spec.unit ?? '%'}
|
||||
tip={spec.tip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareAcademics({
|
||||
schools,
|
||||
data,
|
||||
nationalAverages,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
nationalAverages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const urns = schools.map((school) => school.urn);
|
||||
const schoolNames = schools.map((school) => school.school_name);
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
|
||||
if (isSecondary) {
|
||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
||||
const banding = urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="How students do academically"
|
||||
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally — the wording is DfE's own banding."
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{att8[i] != null ? (
|
||||
<>
|
||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
||||
{att8Anchor != null && (
|
||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{banding[i] ? (
|
||||
<Chip
|
||||
tone={
|
||||
/well above|above/i.test(banding[i] as string)
|
||||
? 'good'
|
||||
: /well below|below/i.test(banding[i] as string)
|
||||
? 'warn'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{banding[i]}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
|
||||
Grade 5+ in English & maths
|
||||
</RowLabel>
|
||||
{schools.map((school, 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>}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||
{schools.map((school, 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>}
|
||||
</Cell>
|
||||
))}
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const national = nationalAverages?.primary;
|
||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="How children do academically"
|
||||
how="Results from national tests and teacher assessments at the end of Year 6 — writing is assessed by teachers, not tested. Each line runs from 0–100%; the grey tick marks the England average, so dots to its right are above average."
|
||||
>
|
||||
<div className={s.card}>
|
||||
{TIER1_PRIMARY.map((spec) => (
|
||||
<Strip
|
||||
key={spec.metric}
|
||||
spec={spec}
|
||||
data={data}
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
/>
|
||||
))}
|
||||
|
||||
<details className={styles.moreMeasures}>
|
||||
<summary>More measures — grammar, punctuation & spelling, science, average scaled scores</summary>
|
||||
{TIER2_PRIMARY.map((spec) => (
|
||||
<Strip
|
||||
key={spec.metric}
|
||||
spec={spec}
|
||||
data={data}
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
/>
|
||||
))}
|
||||
<p className={styles.stripNote}>
|
||||
The scaled-score strips show the 100–120 window of the full 80–120 range; 100 is the
|
||||
expected standard. Where an England tick is missing, the official figure isn't in
|
||||
our dataset yet.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{disadvantaged.some((v) => v != null) && (
|
||||
<SectionGrid schools={schools}>
|
||||
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
|
||||
Children from lower-income families
|
||||
</RowLabel>
|
||||
{schools.map((school, i) => {
|
||||
const value = disadvantaged[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{value != null ? (
|
||||
<>
|
||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||
{Math.round(value)}%
|
||||
</span>{' '}
|
||||
{disadvantagedAnchor != null && (
|
||||
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
||||
`Well above the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'close' &&
|
||||
`Around the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'below' &&
|
||||
`Below the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</SectionGrid>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Getting a place — admissions framed the way the expert review requires:
|
||||
* total applications are "named on N forms" (any preference rank, not
|
||||
* head-to-head), one consistent chip metric (first-preference success),
|
||||
* equal-preference and offers-vs-intake explanations up front.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
import { CHART_COLORS } from '@/lib/utils';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
export function CompareAdmissions({
|
||||
schools,
|
||||
data,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
}) {
|
||||
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
|
||||
const anyData = rows.some(Boolean);
|
||||
const entryYear = rows.find(Boolean)?.year;
|
||||
const entryLabel = entryYear
|
||||
? `September ${String(entryYear).slice(0, 4)} entry`
|
||||
: 'the most recent admissions round';
|
||||
|
||||
if (!anyData) {
|
||||
return (
|
||||
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
|
||||
<></>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Getting a place"
|
||||
how={
|
||||
<>
|
||||
From the most recent admissions round ({entryLabel}). "First choice" means
|
||||
families who ranked the school top of their application form — officially a "first
|
||||
preference". Schools never see your ranking: places are decided only by the
|
||||
school's admission criteria, so listing a school lower down never hurts your chances.
|
||||
These are National Offer Day offers — waiting lists and appeals can change the final
|
||||
intake.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure
|
||||
tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place."
|
||||
label="Interest in the school"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const a = rows[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{a?.total_applications != null && a?.places_offered != null ? (
|
||||
<>
|
||||
Named on <strong>{a.total_applications.toLocaleString('en-GB')}</strong> forms ·{' '}
|
||||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure label="First-choice families offered a place">
|
||||
{schools.map((school, i) => {
|
||||
const summary = summariseAdmissions(rows[i]);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{summary.firstPrefPct != null ? (
|
||||
<>
|
||||
<strong>{summary.firstPrefPct}%</strong>{' '}
|
||||
{summary.chip && summary.chip.tone === 'warn' && (
|
||||
<Chip tone="warn">{summary.chip.text}</Chip>
|
||||
)}
|
||||
<span className={s.barMini}>
|
||||
<i
|
||||
style={{
|
||||
width: `${summary.firstPrefPct}%`,
|
||||
background: CHART_COLORS[i % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure label="What this means">
|
||||
{schools.map((school, i) => {
|
||||
const a = rows[i];
|
||||
const summary = summariseAdmissions(a);
|
||||
let text: string | null = null;
|
||||
if (summary.firstPrefPct != null) {
|
||||
if (summary.firstPrefPct >= 100) {
|
||||
text = `Every family who put ${school.school_name} first got a place.`;
|
||||
} else if (summary.firstPrefPct >= 90) {
|
||||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||||
} else if (a?.oversubscribed) {
|
||||
text =
|
||||
'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||||
} else {
|
||||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{text ? <span className={s.small}>{text}</span> : <span className={s.small}>—</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* At a glance — the short version of every section below it. Copy verbatim
|
||||
* from the reviewed mockups. Report-card cells summarise by counting graded
|
||||
* areas (best first) and always NAME problem areas; safeguarding is a
|
||||
* separate line, never a count.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
latestValues,
|
||||
ofstedDisplay,
|
||||
summariseAdmissions,
|
||||
verdict,
|
||||
type ReportCardSummary,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
function ReportCardChips({ summary }: { summary: ReportCardSummary }) {
|
||||
return (
|
||||
<>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
|
||||
<span className={s.chipStack}>
|
||||
{summary.counts.map((c) => (
|
||||
<Chip key={c.label} tone={c.label === 'Expected standard' ? 'neutral' : 'good'}>
|
||||
{c.count} area{c.count === 1 ? '' : 's'} {c.label}
|
||||
</Chip>
|
||||
))}
|
||||
{summary.problems.map((p) => (
|
||||
<Chip key={p.areaLabel} tone={p.label === 'Urgent improvement' ? 'bad' : 'warn'}>
|
||||
{p.areaLabel}: {p.label}
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
{summary.allClear && 'No areas need attention · '}
|
||||
{summary.safeguarding === 'met' && 'Safeguarding met'}
|
||||
{summary.safeguarding === 'not_met' && 'Safeguarding not met'}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareAtAGlance({
|
||||
schools,
|
||||
data,
|
||||
nationalAverages,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
nationalAverages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const urns = schools.map((school) => school.urn);
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
const headlineValues = latestValues(data, urns, headlineKey);
|
||||
const anchor = isSecondary
|
||||
? nationalAverages?.secondary?.attainment_8_score
|
||||
: nationalAverages?.primary?.rwm_expected_pct;
|
||||
const medianPupils = isSecondary
|
||||
? benchmarks?.secondary?.median_pupils
|
||||
: benchmarks?.primary?.median_pupils;
|
||||
|
||||
return (
|
||||
<Section title="At a glance" how="The short version — each row below is explained in its own section further down.">
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Latest Ofsted inspection">
|
||||
{schools.map((school, i) => {
|
||||
const display = ofstedDisplay(data[String(school.urn)]?.ofsted);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
|
||||
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
|
||||
<>
|
||||
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
|
||||
{display.gradeLabel}
|
||||
</span>
|
||||
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
|
||||
</>
|
||||
)}
|
||||
{display.kind === 'transitional' && (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>Sub-judgements only</span>
|
||||
</>
|
||||
)}
|
||||
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip={
|
||||
isSecondary
|
||||
? 'Average Attainment 8 score across GCSE subjects (latest year).'
|
||||
: '% of Year 6 pupils reaching the expected standard in reading, writing and maths (latest year).'
|
||||
}
|
||||
label={isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const value = headlineValues[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{value != null ? (
|
||||
<>
|
||||
<span className={s.big}>{isSecondary ? value.toFixed(1) : `${Math.round(value)}%`}</span>{' '}
|
||||
{anchor != null && (
|
||||
<Chip
|
||||
tone={
|
||||
verdict(value, anchor) === 'above'
|
||||
? 'good'
|
||||
: verdict(value, anchor) === 'below'
|
||||
? 'warn'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{verdict(value, anchor) === 'above' && 'Above England average'}
|
||||
{verdict(value, anchor) === 'close' && 'Close to England average'}
|
||||
{verdict(value, anchor) === 'below' && 'Below England average'}
|
||||
</Chip>
|
||||
)}
|
||||
{anchor != null && (
|
||||
<span className={s.small}>
|
||||
England average {isSecondary ? anchor.toFixed(1) : `${Math.round(anchor)}%`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Getting a place">
|
||||
{schools.map((school, i) => {
|
||||
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{summary.chip ? (
|
||||
<>
|
||||
<Chip tone={summary.chip.tone}>{summary.chip.text}</Chip>
|
||||
{summary.interest && <span className={s.small}>{summary.interest}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No admissions data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Size">
|
||||
{schools.map((school, i) => {
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
||||
let sizeNote: string | null = null;
|
||||
if (pupils != null && medianPupils != null) {
|
||||
if (pupils >= medianPupils * 1.5) sizeNote = 'Much 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.9) sizeNote = 'Smaller than average';
|
||||
else sizeNote = 'About average size';
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{pupils != null ? (
|
||||
<>
|
||||
{pupils.toLocaleString('en-GB')} pupils
|
||||
{sizeNote && <span className={s.small}>{sizeNote}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Who goes there — the school's community from the latest census plus GIAS
|
||||
* facts. Benchmark chips use the computed state-school averages and must
|
||||
* carry their provenance wording (never "England average" for computed
|
||||
* figures). Copy verbatim from the reviewed mockups.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { verdict } from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null {
|
||||
if (part == null || total == null || total === 0) return null;
|
||||
return `${Math.round((part / total) * 100)}%`;
|
||||
}
|
||||
|
||||
export function CompareCommunity({
|
||||
schools,
|
||||
data,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
|
||||
|
||||
const fsmChip = (value: number | null) => {
|
||||
const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null;
|
||||
if (value == null || anchor == null) return null;
|
||||
const v = verdict(value, anchor, 3);
|
||||
return (
|
||||
<Chip tone="neutral">
|
||||
{v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`}
|
||||
{v === 'close' && `About the state-school average (${Math.round(anchor)}%)`}
|
||||
{v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
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."
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Pupils on roll">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined;
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const pupils = census?.total_pupils ?? info?.gias_total_pupils ?? null;
|
||||
const capacity = info?.capacity ?? null;
|
||||
let capNote: string | null = null;
|
||||
if (pupils != null && capacity != null && capacity > 0) {
|
||||
capNote =
|
||||
pupils >= capacity
|
||||
? `${capacity.toLocaleString('en-GB')} places — at or above capacity`
|
||||
: `of ${capacity.toLocaleString('en-GB')} places (${Math.round((pupils / capacity) * 100)}% full)`;
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{pupils != null ? (
|
||||
<>
|
||||
{pupils.toLocaleString('en-GB')}
|
||||
{capNote && <span className={s.small}>{capNote}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Girls / boys">
|
||||
{schools.map((school, i) => {
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const girls = pctSplit(census?.female_pupils, census?.total_pupils);
|
||||
const boys = pctSplit(census?.male_pupils, census?.total_pupils);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{girls && boys ? `${girls} / ${boys}` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. Benchmark computed across state schools in our dataset."
|
||||
label="Free school meals"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{fsm != null ? (
|
||||
<>
|
||||
{Math.round(fsm)}% {fsmChip(fsm)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset."
|
||||
label="English as an additional language"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const eal = data[String(school.urn)]?.census?.eal_pct ?? null;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{eal != null ? `${Math.round(eal)}%` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils receiving SEN support (not including EHC plans). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign. State-school average computed from our dataset."
|
||||
label="Extra learning support (SEN)"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const rows = data[String(school.urn)]?.yearly_data ?? [];
|
||||
let sen: number | null = null;
|
||||
for (let r = rows.length - 1; r >= 0; r--) {
|
||||
if (rows[r].sen_support_pct != null) {
|
||||
sen = rows[r].sen_support_pct;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const high =
|
||||
sen != null && bench?.sen_support_pct != null && sen >= bench.sen_support_pct * 1.75;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{sen != null ? (
|
||||
<>
|
||||
{Math.round(sen)}% {high && <Chip tone="neutral">Well above average</Chip>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Faith character">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
const faith = info?.religious_denomination;
|
||||
const none = !faith || faith === 'Does not apply' || faith === 'None';
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{none ? 'None' : faith}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Ages">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{info?.age_range || <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Run by">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
const trust = info?.trust_name;
|
||||
const la = info?.local_authority ?? school.local_authority;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{trust ? trust : la ? `${la} council` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Ofsted section — one visual grammar for inspection detail across all
|
||||
* three regimes (legacy graded, interim carried-forward, renewed-framework
|
||||
* report card). Copy comes verbatim from the reviewed mockups.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
OFSTED_LEGACY_GRADES,
|
||||
ofstedDisplay,
|
||||
rcAreaLabel,
|
||||
type OfstedDisplay,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
|
||||
1: 'good',
|
||||
2: 'good',
|
||||
3: 'warn',
|
||||
4: 'bad',
|
||||
};
|
||||
|
||||
const RC_CODE_TONE = (code: number): 'good' | 'warn' | 'bad' | 'neutral' =>
|
||||
code <= 2 ? 'good' : code === 3 ? 'neutral' : code === 4 ? 'warn' : 'bad';
|
||||
|
||||
function formatInspectionDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function yearsSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
function ResultCell({ display }: { display: OfstedDisplay }) {
|
||||
if (display.kind === 'none') {
|
||||
return <span className={s.small}>No inspection outcome in our dataset</span>;
|
||||
}
|
||||
if (display.kind === 'report_card') {
|
||||
return (
|
||||
<>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
|
||||
<span className={s.small}>New-style inspection — no overall grade is given</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (display.kind === 'transitional') {
|
||||
return (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
Inspected under transitional framework (sub-judgements only)
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
|
||||
{display.gradeLabel}
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
{display.carriedForward
|
||||
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
|
||||
: 'Overall grade (older-style inspection)'}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JudgementDetailCell({
|
||||
ofsted,
|
||||
display,
|
||||
schoolName,
|
||||
}: {
|
||||
ofsted: OfstedInspection;
|
||||
display: OfstedDisplay;
|
||||
schoolName: string;
|
||||
}) {
|
||||
if (display.kind === 'report_card') {
|
||||
const entries = Object.entries(ofsted.report_card ?? {});
|
||||
return (
|
||||
<div className={s.rcList}>
|
||||
{entries.map(([key, entry]) => (
|
||||
<div key={key} className={s.rcRow}>
|
||||
<span className={s.rcArea}>{rcAreaLabel(key)}</span>
|
||||
<Chip tone={RC_CODE_TONE(entry.code)}>{entry.label}</Chip>
|
||||
</div>
|
||||
))}
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={s.rcRow}>
|
||||
<span className={s.rcArea}>Safeguarding</span>
|
||||
<Chip tone={ofsted.rc_safeguarding_met ? 'good' : 'bad'}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legacyAreas: Array<[string, number | null]> = [
|
||||
['Quality of education', ofsted.quality_of_education],
|
||||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||||
['Personal development', ofsted.personal_development],
|
||||
['Leadership & management', ofsted.leadership_management],
|
||||
['Early years provision', ofsted.early_years_provision],
|
||||
];
|
||||
const published = legacyAreas.filter(([, grade]) => grade != null);
|
||||
|
||||
if (published.length === 0) {
|
||||
return (
|
||||
<span className={s.small}>
|
||||
We don't hold area-by-area detail for this inspection — see {schoolName}'s
|
||||
Ofsted page for the full report.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={s.rcList}>
|
||||
{published.map(([label, grade]) => (
|
||||
<div key={label} className={s.rcRow}>
|
||||
<span className={s.rcArea}>{label}</span>
|
||||
<Chip tone={GRADE_TONE[grade as number] ?? 'neutral'}>
|
||||
{OFSTED_LEGACY_GRADES[grade as number] ?? String(grade)}
|
||||
</Chip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareOfsted({
|
||||
schools,
|
||||
data,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
}) {
|
||||
const displays = schools.map((school) => ofstedDisplay(data[String(school.urn)]?.ofsted));
|
||||
const kinds = new Set(displays.map((d) => d.kind).filter((k) => k !== 'none'));
|
||||
const mixedRegimes = kinds.size > 1;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Ofsted inspection"
|
||||
how={
|
||||
<>
|
||||
Ofsted is the schools inspectorate. It stopped giving a single overall grade in{' '}
|
||||
<strong>September 2024</strong>; inspections between then and November 2025 kept the
|
||||
area-by-area judgements without an overall grade, and from <strong>November 2025</strong>{' '}
|
||||
new inspections produce a <strong>report card</strong> rating each area of school life on
|
||||
a five-point scale.
|
||||
{mixedRegimes && (
|
||||
<> A report card and an older overall grade aren't directly comparable.</>
|
||||
)}{' '}
|
||||
(Ofsted's "Expected standard" rating is unrelated to the KS2 "expected
|
||||
standard" test measure further down this page.)
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Result">
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
<ResultCell display={displays[i]} />
|
||||
</Cell>
|
||||
))}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Inspected">
|
||||
{schools.map((school, i) => {
|
||||
const ofsted = data[String(school.urn)]?.ofsted;
|
||||
const age = yearsSince(ofsted?.inspection_date ?? null);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{formatInspectionDate(ofsted?.inspection_date ?? null)}{' '}
|
||||
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life."
|
||||
label="Judgement detail"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const ofsted = data[String(school.urn)]?.ofsted;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{ofsted ? (
|
||||
<JudgementDetailCell
|
||||
ofsted={ofsted}
|
||||
display={displays[i]}
|
||||
schoolName={school.school_name}
|
||||
/>
|
||||
) : (
|
||||
<span className={s.small}>No inspection in our dataset</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed."
|
||||
label="Ofsted page"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const url =
|
||||
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
|
||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{school.school_name}'s Ofsted page →
|
||||
</a>
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.explore {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.explore summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark);
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.explore[open] summary {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
border: 1px solid var(--border-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.picker label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.picker select {
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progressNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
|
||||
its container's height — which must be *definite*. A min-height alone does
|
||||
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
|
||||
to its ~150px default (a squashed sliver). Give it a real height. */
|
||||
.chartBox {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Explore trends — the full grouped metric catalogue (nothing from the old
|
||||
* compare page is lost; spec §4's tier 3) driving the year-by-year chart with
|
||||
* its England reference line. Matches the mockup: a measure picker and the
|
||||
* chart only (no data table).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
|
||||
import { track } from '@/lib/analytics';
|
||||
import { Section } from './sectionShared';
|
||||
import styles from './TrendsExplorer.module.css';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('../ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category);
|
||||
export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category);
|
||||
|
||||
export function TrendsExplorer({
|
||||
schools,
|
||||
data,
|
||||
metrics,
|
||||
metric,
|
||||
onMetricChange,
|
||||
isPrimaryPhase,
|
||||
nationalAverages,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
metrics: MetricDefinition[];
|
||||
/** Controlled: the page owns the metric so the URL contract survives. */
|
||||
metric: string;
|
||||
onMetricChange: (metric: string) => void;
|
||||
isPrimaryPhase: boolean;
|
||||
nationalAverages?: NationalAverages;
|
||||
}) {
|
||||
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
|
||||
const metricDef = metrics.find((m) => m.key === metric);
|
||||
const metricLabel = metricDef?.label || metric;
|
||||
|
||||
const nationalByYear: Record<number, number | null> = {};
|
||||
for (const entry of nationalAverages?.by_year ?? []) {
|
||||
const block = isPrimaryPhase ? entry.primary : entry.secondary;
|
||||
nationalByYear[entry.year] = block?.[metric] ?? null;
|
||||
}
|
||||
|
||||
const handleMetricChange = (next: string) => {
|
||||
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
||||
onMetricChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Explore trends"
|
||||
how="The full year-by-year explorer for any measure, with the England average as a dashed reference line where official figures exist."
|
||||
>
|
||||
<details className={styles.explore} open>
|
||||
<summary>Year-by-year trends</summary>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.picker}>
|
||||
<label htmlFor="trends-metric-select">Measure:</label>
|
||||
<select
|
||||
id="trends-metric-select"
|
||||
value={metric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
>
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const group = filteredMetrics.filter((m) => m.category === category);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{group.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{metricDef?.description && <span className={styles.desc}>{metricDef.description}</span>}
|
||||
</div>
|
||||
|
||||
{metric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the
|
||||
national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.chartBox}>
|
||||
<ComparisonChart
|
||||
comparisonData={data}
|
||||
schools={schools}
|
||||
metric={metric}
|
||||
metricLabel={metricLabel}
|
||||
nationalByYear={nationalByYear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/* Shared layout for the compare screen's measure-first sections.
|
||||
Mobile base: each row-label becomes a measure header and each school cell
|
||||
stacks under it (colour-coded via the cell's ::before school tag).
|
||||
Desktop (≥761px): the mockups' grid — 200px row-label column + one column
|
||||
per school (2–4 columns supported via --school-count). */
|
||||
|
||||
.section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.how {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.35rem 0 0 0.95rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mobile base: each measure is a card; each cell is a school row led by a
|
||||
colour dot + short name. `display: contents` at ≥761px dissolves the card
|
||||
back into the shared grid. */
|
||||
.measure {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.75rem 0.85rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0 0.1rem;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* The school name gets its own full-width line above the value — real
|
||||
school names are long and varied, so a fixed-width name column truncated
|
||||
them ("Our Lady Queen of H…") or crowded the value. */
|
||||
.cellTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--sc, var(--text-secondary));
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.cellDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot, var(--text-muted));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.small {
|
||||
display: block;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chipGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.chipWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
}
|
||||
|
||||
.chipBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.chipNeutral {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help {
|
||||
display: inline-flex;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badgeGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
}
|
||||
|
||||
.badgeBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.rcList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rcRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.rcArea {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chipStack {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.barMini {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.barMini > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
.grid {
|
||||
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||
gap: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Dissolve the per-measure card so its label + cells become grid items of
|
||||
.grid, keeping columns aligned across every measure. */
|
||||
.measure {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.cellTag {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.85rem 0.5rem 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: block;
|
||||
padding: 0.85rem 0.25rem;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
flex-basis: auto;
|
||||
padding-left: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Small shared pieces for the compare sections: the section shell, the
|
||||
* row-label + per-school-cell grid, and tone-mapped chips. Copy passed into
|
||||
* these comes verbatim from the reviewed mockups
|
||||
* (docs/superpowers/specs/mockups/) — do not paraphrase it here.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||
import styles from './compareSections.module.css';
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
how,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
how?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
{how && <p className={styles.how}>{how}</p>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionGrid({
|
||||
schools,
|
||||
children,
|
||||
}: {
|
||||
schools: School[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles.grid}
|
||||
style={{ '--school-count': schools.length } as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
|
||||
return (
|
||||
<div className={styles.rowLabel}>
|
||||
{children}
|
||||
{tip && (
|
||||
<span className={styles.help} title={tip} aria-label={tip}>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One measure = its row label plus a cell per school. `display: contents` on
|
||||
* desktop (see CSS) makes these flow into the section grid as if this wrapper
|
||||
* weren't here, keeping columns aligned across measures; on mobile the wrapper
|
||||
* becomes a card so each measure reads as its own block.
|
||||
*/
|
||||
export function Measure({
|
||||
label,
|
||||
tip,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
tip?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.measure}>
|
||||
<RowLabel tip={tip}>{label}</RowLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Cell({
|
||||
school,
|
||||
index,
|
||||
children,
|
||||
}: {
|
||||
school: School;
|
||||
index: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles.cell}
|
||||
style={
|
||||
{
|
||||
'--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length],
|
||||
'--dot': CHART_COLORS[index % CHART_COLORS.length],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Mobile-only per-school tag (dot + short name); hidden on desktop,
|
||||
where the column header identifies the school. */}
|
||||
<span className={styles.cellTag}>
|
||||
<span className={styles.cellDot} aria-hidden="true" />
|
||||
{shortName(school.school_name)}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral';
|
||||
|
||||
const CHIP_TONE_CLASS: Record<ChipTone, string> = {
|
||||
good: styles.chipGood,
|
||||
warn: styles.chipWarn,
|
||||
bad: styles.chipBad,
|
||||
neutral: styles.chipNeutral,
|
||||
};
|
||||
|
||||
export function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }) {
|
||||
return <span className={`${styles.chip} ${CHIP_TONE_CLASS[tone]}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export const sectionStyles = styles;
|
||||
@@ -1,50 +1,18 @@
|
||||
/**
|
||||
* Custom hook for managing school comparison state
|
||||
* Uses shared context for real-time updates across components
|
||||
* Custom hook for managing school comparison state.
|
||||
*
|
||||
* This hook is mounted on every page via the global Navigation and
|
||||
* ComparisonToast, so it must stay cheap — it exposes basket state only.
|
||||
* The compare page fetches `/api/compare` itself (ComparisonView); nothing
|
||||
* ever read the comparison payload from here, so the previous per-page SWR
|
||||
* fetch (which fired on every page whenever the basket was non-empty) was
|
||||
* dead weight and has been removed.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/api';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import type { ComparisonResponse } from '@/lib/types';
|
||||
|
||||
export function useComparison() {
|
||||
const {
|
||||
selectedSchools,
|
||||
addSchool,
|
||||
removeSchool,
|
||||
replaceSchools,
|
||||
clearAll,
|
||||
isSelected,
|
||||
canAddMore,
|
||||
isInitialized,
|
||||
} = useComparisonContext();
|
||||
|
||||
// Fetch comparison data for selected schools
|
||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
||||
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
|
||||
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
selectedSchools,
|
||||
comparisonData: data?.comparison,
|
||||
isLoading,
|
||||
error,
|
||||
addSchool,
|
||||
removeSchool,
|
||||
replaceSchools,
|
||||
clearAll,
|
||||
isSelected,
|
||||
canAddMore,
|
||||
isInitialized,
|
||||
mutate,
|
||||
};
|
||||
return useComparisonContext();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Pure series-building for the comparison trend chart, extracted from
|
||||
* ComparisonChart so it is unit-testable without a canvas.
|
||||
*
|
||||
* Chart truthfulness rules (spec §8.1): every academic year between the
|
||||
* first and last data point appears on the axis — cancelled test years
|
||||
* (2019/20, 2020/21) and the unpublished 2021/22 school-level year render
|
||||
* as real gaps, never as compressed time; school lines never bridge gaps.
|
||||
*/
|
||||
|
||||
import type { ComparisonData } from './types';
|
||||
|
||||
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
||||
function nextAcademicYear(year: number): number {
|
||||
const start = Math.floor(year / 100);
|
||||
const end = year % 100;
|
||||
return (start + 1) * 100 + (end + 1);
|
||||
}
|
||||
|
||||
/** Every academic year from min(years) to max(years), inclusive. */
|
||||
export function fillAcademicYears(years: number[]): number[] {
|
||||
if (years.length === 0) return [];
|
||||
const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b);
|
||||
const out: number[] = [];
|
||||
let y = ints[0];
|
||||
const last = ints[ints.length - 1];
|
||||
while (y <= last && out.length < 50) {
|
||||
out.push(y);
|
||||
y = nextAcademicYear(y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CompareChartSeries {
|
||||
label: string;
|
||||
data: Array<number | null>;
|
||||
/** Index into CHART_COLORS / point styles. */
|
||||
schoolIndex: number;
|
||||
spanGaps: false;
|
||||
}
|
||||
|
||||
export interface EnglandSeries {
|
||||
label: 'England average';
|
||||
data: Array<number | null>;
|
||||
borderDash: [number, number];
|
||||
spanGaps: false;
|
||||
}
|
||||
|
||||
export interface CompareChart {
|
||||
years: number[];
|
||||
schoolDatasets: CompareChartSeries[];
|
||||
englandDataset: EnglandSeries | null;
|
||||
/** 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". */
|
||||
showUnpublished202122Note: boolean;
|
||||
}
|
||||
|
||||
export function buildCompareChart(
|
||||
comparisonData: Record<string, ComparisonData>,
|
||||
schools: Array<{ urn: number; school_name: string }>,
|
||||
metric: string,
|
||||
nationalByYear?: Record<number, number | null | undefined>,
|
||||
): CompareChart {
|
||||
const rawYears = schools.flatMap(
|
||||
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
||||
);
|
||||
const years = fillAcademicYears(rawYears);
|
||||
|
||||
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
||||
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
||||
const byYear = new Map<number, Record<string, unknown>>();
|
||||
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
||||
return {
|
||||
label: school.school_name,
|
||||
data: years.map((year) => {
|
||||
const v = byYear.get(year)?.[metric];
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
||||
}),
|
||||
schoolIndex,
|
||||
spanGaps: false,
|
||||
};
|
||||
});
|
||||
|
||||
let englandDataset: EnglandSeries | null = null;
|
||||
if (nationalByYear) {
|
||||
const data = years.map((year) => {
|
||||
const v = nationalByYear[year];
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
||||
});
|
||||
if (data.some((v) => v != null)) {
|
||||
englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false };
|
||||
}
|
||||
}
|
||||
|
||||
const idx202122 = years.indexOf(202122);
|
||||
const showUnpublished202122Note =
|
||||
idx202122 >= 0 &&
|
||||
englandDataset?.data[idx202122] != null &&
|
||||
schoolDatasets.every((ds) => ds.data[idx202122] == null);
|
||||
|
||||
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function summariseAdmissions(
|
||||
a: SchoolAdmissions | null | undefined,
|
||||
): AdmissionsSummary {
|
||||
if (!a) return { firstPrefPct: null, chip: null, interest: null };
|
||||
|
||||
const pct =
|
||||
a.first_preference_offer_pct != null
|
||||
? Math.round(a.first_preference_offer_pct)
|
||||
: null;
|
||||
|
||||
let chip: AdmissionsSummary['chip'] = null;
|
||||
if (pct != null) {
|
||||
if (pct >= 100) {
|
||||
chip = { tone: 'good', text: 'All first choices offered' };
|
||||
} else if (pct < 75) {
|
||||
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
||||
} else {
|
||||
chip = { tone: pct >= 90 ? 'good' : 'neutral', text: `${pct}% of first choices offered` };
|
||||
}
|
||||
}
|
||||
|
||||
const interest =
|
||||
a.total_applications != null && a.places_offered != null
|
||||
? `Named on ${a.total_applications.toLocaleString('en-GB')} forms · ${a.places_offered.toLocaleString('en-GB')} places`
|
||||
: null;
|
||||
|
||||
return { firstPrefPct: pct, chip, interest };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress bands — DfE confidence-interval methodology
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function progressBand(
|
||||
score: number | null,
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): 'above' | 'average' | 'below' | null {
|
||||
if (score == null || lower == null || upper == null) return null;
|
||||
if (lower > 0) return 'above';
|
||||
if (upper < 0) return 'below';
|
||||
return 'average';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dot-strip geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StripPoint {
|
||||
/** 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;
|
||||
});
|
||||
}
|
||||
@@ -99,6 +99,21 @@ 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;
|
||||
/** 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. */
|
||||
report_card?: Record<string, ReportCardEntry>;
|
||||
/** The school's page on ofsted.gov.uk (never a deep report link). */
|
||||
ofsted_page_url?: string;
|
||||
report_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ReportCardEntry {
|
||||
code: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SchoolCensus {
|
||||
@@ -129,6 +144,12 @@ export interface SchoolAdmissions {
|
||||
/** 1st-preference applications per place offered (>1 means oversubscribed). */
|
||||
oversubscription_ratio?: number | null;
|
||||
oversubscribed: boolean | null;
|
||||
total_offers?: number | null;
|
||||
second_preference_offers?: number | null;
|
||||
third_preference_offers?: number | null;
|
||||
/** Applications naming this school from families in another LA, and offers to them. */
|
||||
cross_la_applications?: number | null;
|
||||
cross_la_offers?: number | null;
|
||||
}
|
||||
|
||||
export interface SenDetail {
|
||||
@@ -172,6 +193,22 @@ export interface SchoolResult {
|
||||
school_id: number;
|
||||
year: number;
|
||||
|
||||
// Progress confidence intervals + writing working-towards (published for
|
||||
// years with progress measures, i.e. up to 2022/23)
|
||||
reading_progress_lower_ci?: number | null;
|
||||
reading_progress_upper_ci?: number | null;
|
||||
writing_progress_lower_ci?: number | null;
|
||||
writing_progress_upper_ci?: number | null;
|
||||
writing_working_towards_pct?: number | null;
|
||||
maths_progress_lower_ci?: number | null;
|
||||
maths_progress_upper_ci?: number | null;
|
||||
|
||||
// KS4 banding and disadvantage gaps
|
||||
/** DfE's own plain-English Progress 8 label, e.g. "Well above average". */
|
||||
progress_8_banding?: string | null;
|
||||
attainment_8_disadvantage_gap?: number | null;
|
||||
progress_8_disadvantage_gap?: number | null;
|
||||
|
||||
// Pupil numbers
|
||||
total_pupils: number | null;
|
||||
eligible_pupils: number | null;
|
||||
@@ -308,10 +345,48 @@ export interface SchoolDetailsResponse {
|
||||
export interface ComparisonData {
|
||||
school_info: School;
|
||||
yearly_data: SchoolResult[];
|
||||
// Supplementary blocks (additive; absent on an old backend)
|
||||
ofsted?: OfstedInspection | null;
|
||||
census?: SchoolCensus | null;
|
||||
admissions?: SchoolAdmissions | null;
|
||||
admissions_history?: SchoolAdmissions[];
|
||||
deprivation?: SchoolDeprivation | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkBlock {
|
||||
eal_pct: number | null;
|
||||
sen_support_pct: number | null;
|
||||
disadvantaged_pct: number | null;
|
||||
fsm_pct?: number | null;
|
||||
median_pupils: number | null;
|
||||
/** Primary only — weighted by cohort size. */
|
||||
disadvantaged_rwm_expected_pct?: number | null;
|
||||
}
|
||||
|
||||
/** Computed from our dataset — NOT official DfE figures. UI copy must say
|
||||
* "state-school average (computed from our dataset)" (the `source` string). */
|
||||
export interface Benchmarks {
|
||||
source: string;
|
||||
year: number;
|
||||
primary: BenchmarkBlock;
|
||||
secondary: BenchmarkBlock;
|
||||
}
|
||||
|
||||
export interface NationalAverages {
|
||||
year: number;
|
||||
primary: Record<string, number>;
|
||||
secondary: Record<string, number>;
|
||||
by_year: Array<{
|
||||
year: number;
|
||||
primary: Record<string, number>;
|
||||
secondary: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ComparisonResponse {
|
||||
comparison: Record<string, ComparisonData>;
|
||||
national_averages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
}
|
||||
|
||||
export interface RankingItem {
|
||||
|
||||
@@ -59,6 +59,24 @@ export function truncate(text: string, maxLength: number): string {
|
||||
return text.slice(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact school label for tight spaces (mobile compare rows, chip bars):
|
||||
* drop the trailing establishment-type words so "Barclay Primary School" →
|
||||
* "Barclay", "St Mary's Catholic Primary School" → "St Mary's". Falls back to
|
||||
* a length-capped truncation for names that don't carry a type suffix.
|
||||
*/
|
||||
export function shortName(name: string, maxLength = 32): string {
|
||||
let s = name
|
||||
.replace(
|
||||
/\s+(primary|junior|infant|nursery|community|foundation|catholic|academy|school|college)\b.*$/i,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (!s) s = name;
|
||||
if (s.length > maxLength) s = s.slice(0, maxLength - 1).trim() + '…';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a school's age range for display, e.g. "3-11" → "Ages 3–11".
|
||||
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
||||
|
||||
Reference in New Issue
Block a user