Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79246edc22 | ||
|
|
64b63b96c8 | ||
|
|
5944d88f0b | ||
|
|
163b501be6 | ||
|
|
80176cac4d | ||
|
|
84baf95f68 | ||
|
|
99b769ca9e | ||
|
|
e8f78a1598 | ||
|
|
8e0b730629 |
@@ -740,6 +740,9 @@ async def compare_schools(
|
|||||||
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
||||||
"age_range": convert_to_native(latest.get("age_range")),
|
"age_range": convert_to_native(latest.get("age_range")),
|
||||||
"gender": convert_to_native(latest.get("gender")),
|
"gender": convert_to_native(latest.get("gender")),
|
||||||
|
# Needed by the admissions "What this means" copy: selective
|
||||||
|
# schools get entrance-test framing, never the distance template.
|
||||||
|
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
||||||
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||||
"capacity": convert_to_native(latest.get("capacity")),
|
"capacity": convert_to_native(latest.get("capacity")),
|
||||||
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ test('comparing two schools shows the parent-first sections side by side', async
|
|||||||
// Every number gets an anchor: at least one England-average tick or label
|
// Every number gets an anchor: at least one England-average tick or label
|
||||||
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Desktop: the sticky school bar shares the sections' grid template
|
||||||
|
// (200px label rail + one column per school) so chips align with the
|
||||||
|
// columns they label.
|
||||||
|
const barTemplate = await page
|
||||||
|
.locator('[aria-label="Schools in this comparison"]')
|
||||||
|
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
|
||||||
|
expect(barTemplate).toMatch(/^200px /);
|
||||||
|
// ...and its label rail carries the comparison caption.
|
||||||
|
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
|
||||||
|
|
||||||
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
||||||
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
||||||
await expect(ofstedLink).toBeVisible();
|
await expect(ofstedLink).toBeVisible();
|
||||||
@@ -227,6 +237,36 @@ test('comparing two secondary schools renders the secondary sections', async ({
|
|||||||
// A KS4 measure proves the secondary academics variant rendered.
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The admissions template must be phase-aware: the primaries' distance
|
||||||
|
// copy ("non-faith primaries") must never appear on a secondary comparison
|
||||||
|
// (expert sign-off must-fix M3).
|
||||||
|
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
||||||
|
// Regression: the first visit stores a basket in localStorage; opening a
|
||||||
|
// link for a DIFFERENT school set then raced a stale fetch for the stored
|
||||||
|
// basket against the new SSR data, blanking every section (including the
|
||||||
|
// trends chart) until a hard refresh.
|
||||||
|
const [s0, s1] = await twoSecondaryUrns(page);
|
||||||
|
const [p0, p1] = await twoPrimaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${s0},${s1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${p0},${p1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
// Give any straggling stale response time to land, then confirm the new
|
||||||
|
// comparison is still on screen.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
|
||||||
|
await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Getting a place — phase and school-type correctness (expert sign-off
|
||||||
|
* must-fixes M1/M3):
|
||||||
|
* - an all-through school's Year 7 round must never render on the primary
|
||||||
|
* tab as if it were Reception odds;
|
||||||
|
* - selective schools get entrance-test framing, and the secondary tab
|
||||||
|
* never shows the primaries' distance template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
|
||||||
|
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string, extra: Partial<School> = {}): School {
|
||||||
|
return { urn, school_name: name, ...extra } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||||
|
return {
|
||||||
|
year: 202627,
|
||||||
|
school_phase: 'Secondary',
|
||||||
|
places_offered: 173,
|
||||||
|
total_applications: 433,
|
||||||
|
first_preference_offer_pct: 83,
|
||||||
|
oversubscribed: true,
|
||||||
|
...partial,
|
||||||
|
} as SchoolAdmissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: info,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: a,
|
||||||
|
admissions_history: a ? [a] : [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CompareAdmissions', () => {
|
||||||
|
it("does not show an all-through school's Year 7 round on the primary tab", () => {
|
||||||
|
// The real M1 scenario: an all-through school (Year 7 round only) beside
|
||||||
|
// a primary with a Reception round.
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const primary = school(138690, 'Barclay Primary School');
|
||||||
|
const data = {
|
||||||
|
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
|
||||||
|
'138690': entry(
|
||||||
|
primary,
|
||||||
|
admissions({
|
||||||
|
school_phase: 'Primary',
|
||||||
|
total_applications: 300,
|
||||||
|
places_offered: 120,
|
||||||
|
first_preference_offer_pct: 96,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
// Hessle's Year 7 figures must not appear…
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/We don't hold Reception admissions data for this school/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// …while Barclay's Reception round renders normally.
|
||||||
|
expect(screen.getByText('300')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase-labels the section empty state when no matching round exists at all', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No Reception admissions data is available for these schools yet/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Year 7 round on the secondary tab', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('433')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('173')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives selective schools entrance-test framing, never the distance template', () => {
|
||||||
|
const grammar = school(136276, 'Watford Grammar School for Boys', {
|
||||||
|
admissions_policy: 'Selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
|
||||||
|
const faithSchool = school(102052, "Bishop Stopford's School", {
|
||||||
|
admissions_policy: 'Non-selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
|
||||||
|
const primary = school(100140, 'Plumcroft Primary School');
|
||||||
|
const data = {
|
||||||
|
'100140': entry(
|
||||||
|
primary,
|
||||||
|
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,31 @@ describe('CompareOfsted', () => {
|
|||||||
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
|
||||||
|
const sentinelSchool = school(6, 'Sentinel School');
|
||||||
|
const sentinelData: Record<string, ComparisonData> = {
|
||||||
|
'6': {
|
||||||
|
school_info: sentinelSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
overall_effectiveness: 2,
|
||||||
|
grade_source: 'graded',
|
||||||
|
quality_of_education: 1,
|
||||||
|
early_years_provision: 9,
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
|
||||||
|
// Real grades render…
|
||||||
|
expect(screen.getByText('Quality of education')).toBeInTheDocument();
|
||||||
|
// …the applicable sixth-form judgement renders (was previously dropped)…
|
||||||
|
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
|
||||||
|
// …and the not-applicable sentinel never appears, neither as area nor code.
|
||||||
|
expect(screen.queryByText('Early years provision')).toBeNull();
|
||||||
|
expect(screen.queryByText('9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
const cardSchool = school(4, 'Dated Card School');
|
const cardSchool = school(4, 'Dated Card School');
|
||||||
const cardData: Record<string, ComparisonData> = {
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
|||||||
@@ -72,5 +72,7 @@ test('an all-secondary comparison renders the sections, not an empty primary tab
|
|||||||
});
|
});
|
||||||
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||||
|
// The sticky bar's rail caption reflects the active phase and count.
|
||||||
|
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
|
||||||
expect(fetchComparison).not.toHaveBeenCalled();
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Regression: opening a compare link while a DIFFERENT basket is stored must
|
||||||
|
* not blank the page.
|
||||||
|
*
|
||||||
|
* The basket hydrates from localStorage first, which can fire a fetch for the
|
||||||
|
* OLD school set; the URL-seed effect then replaces the basket with the URL's
|
||||||
|
* schools (already covered by SSR data, so no new fetch). When the stale
|
||||||
|
* response for the old set finally lands, it must not clobber the fresh SSR
|
||||||
|
* data — that left every section (including the trends chart) empty until a
|
||||||
|
* hard refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visitor's previously stored basket (a different school entirely).
|
||||||
|
const STORED_SCHOOL = school(900, 'Old Stored School');
|
||||||
|
|
||||||
|
// The comparison the URL (and SSR) actually asked for.
|
||||||
|
const URL_DATA = {
|
||||||
|
'100': data(100, 'Alpha Primary'),
|
||||||
|
'200': data(200, 'Beta Primary'),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchComparison.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
|
||||||
|
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
|
||||||
|
|
||||||
|
const pending: Array<(v: unknown) => void> = [];
|
||||||
|
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={URL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: { rwm_expected_pct: 62 },
|
||||||
|
secondary: {},
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[100, 200]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="rwm_expected_pct"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The URL's schools render from SSR data once the basket is reseeded.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The transient stored-basket fetch (for school 900) resolves LATE, after
|
||||||
|
// the basket has moved on to the URL's schools.
|
||||||
|
await act(async () => {
|
||||||
|
for (const resolve of pending) {
|
||||||
|
resolve({
|
||||||
|
comparison: { '900': data(900, 'Old Stored School') },
|
||||||
|
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
|
||||||
|
benchmarks: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page must still show the URL comparison — not go blank.
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
OFSTED_LEGACY_GRADES,
|
OFSTED_LEGACY_GRADES,
|
||||||
|
admissionsForPhase,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
progressBand,
|
progressBand,
|
||||||
rcAreaLabel,
|
rcAreaLabel,
|
||||||
@@ -188,6 +189,44 @@ describe('summariseAdmissions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admissionsForPhase', () => {
|
||||||
|
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
|
||||||
|
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
|
||||||
|
|
||||||
|
it('returns the latest round matching the active phase', () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(data, false)?.year).toBe(202526);
|
||||||
|
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses untagged legacy rows only when no row carries a phase', () => {
|
||||||
|
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
|
||||||
|
|
||||||
|
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(mixed, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing data', () => {
|
||||||
|
expect(admissionsForPhase(null, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('progressBand', () => {
|
describe('progressBand', () => {
|
||||||
it('CI entirely above zero → above', () => {
|
it('CI entirely above zero → above', () => {
|
||||||
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||||
|
|||||||
@@ -148,6 +148,54 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Caption filling the label rail on desktop ("Comparing / 3 primary
|
||||||
|
schools"). Hidden on mobile, where the bar is a row of compact pills. */
|
||||||
|
.barCaption {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
|
||||||
|
grid template as compareSections' .grid — a 200px row-label rail plus one
|
||||||
|
column per school — so each chip sits exactly over the column it labels.
|
||||||
|
The caption occupies the rail; chips flow into the school columns. */
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
.schoolBar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||||
|
gap: 0 0.75rem;
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolChip {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaption {
|
||||||
|
grid-column: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaptionEyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaptionCount {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chipRemove {
|
.chipRemove {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
|
||||||
@@ -85,7 +85,9 @@ export function ComparisonView({
|
|||||||
replaceSchools(urlSchools);
|
replaceSchools(urlSchools);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
// Re-seed when a client-side navigation lands on a different ?urns= set
|
||||||
|
// (initialUrns/initialData are new props on the same component instance).
|
||||||
|
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
||||||
|
|
||||||
@@ -128,8 +130,18 @@ export function ComparisonView({
|
|||||||
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||||
if (covered) return;
|
if (covered) return;
|
||||||
|
|
||||||
|
// Guard against out-of-order responses: while the basket hydrates from
|
||||||
|
// localStorage it can transiently hold a DIFFERENT school set than the
|
||||||
|
// URL, firing a fetch for schools the user is no longer comparing. That
|
||||||
|
// stale response must not replace data for the current set — it blanked
|
||||||
|
// every section until a hard refresh. Cleanup marks the run cancelled
|
||||||
|
// when urnKey moves on, so only the current selection's response is
|
||||||
|
// applied (replacing the map keeps it bounded and guarantees a re-added
|
||||||
|
// school is refetched fresh rather than served a lingering old entry).
|
||||||
|
let cancelled = false;
|
||||||
fetchComparison(urnKey, { cache: 'no-store' })
|
fetchComparison(urnKey, { cache: 'no-store' })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
setComparisonData(data.comparison);
|
setComparisonData(data.comparison);
|
||||||
setNationalAverages(data.national_averages);
|
setNationalAverages(data.national_averages);
|
||||||
setBenchmarks(data.benchmarks);
|
setBenchmarks(data.benchmarks);
|
||||||
@@ -140,6 +152,9 @@ export function ComparisonView({
|
|||||||
// destroy a working comparison the user is looking at.
|
// destroy a working comparison the user is looking at.
|
||||||
console.error('Failed to fetch comparison:', err);
|
console.error('Failed to fetch comparison:', err);
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [urnKey, isInitialized]);
|
}, [urnKey, isInitialized]);
|
||||||
|
|
||||||
const primarySchools = selectedSchools.filter((school) => {
|
const primarySchools = selectedSchools.filter((school) => {
|
||||||
@@ -342,8 +357,22 @@ export function ComparisonView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Sticky school bar — column identity while scrolling */}
|
{/* Sticky school bar — column identity while scrolling. On desktop
|
||||||
<div className={styles.schoolBar} aria-label="Schools in this comparison">
|
it shares the sections' grid template (via --school-count) so
|
||||||
|
each chip sits exactly over the column it labels. */}
|
||||||
|
<div
|
||||||
|
className={styles.schoolBar}
|
||||||
|
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||||||
|
aria-label="Schools in this comparison"
|
||||||
|
>
|
||||||
|
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
|
||||||
|
<div className={styles.barCaption}>
|
||||||
|
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||||||
|
<span className={styles.barCaptionCount}>
|
||||||
|
{activeSchools.length} {comparePhase} school
|
||||||
|
{activeSchools.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{activeSchools.map((school, index) => (
|
{activeSchools.map((school, index) => (
|
||||||
<div
|
<div
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
@@ -393,7 +422,11 @@ export function ComparisonView({
|
|||||||
benchmarks={benchmarks}
|
benchmarks={benchmarks}
|
||||||
isSecondary={!isPrimary}
|
isSecondary={!isPrimary}
|
||||||
/>
|
/>
|
||||||
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
<CompareAdmissions
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
isSecondary={!isPrimary}
|
||||||
|
/>
|
||||||
<CompareCommunity
|
<CompareCommunity
|
||||||
schools={activeSchools}
|
schools={activeSchools}
|
||||||
data={activeComparisonData}
|
data={activeComparisonData}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import type { ComparisonData, School } from '@/lib/types';
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
import { CHART_COLORS } from '@/lib/utils';
|
import { CHART_COLORS } from '@/lib/utils';
|
||||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -15,11 +15,17 @@ import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from '.
|
|||||||
export function CompareAdmissions({
|
export function CompareAdmissions({
|
||||||
schools,
|
schools,
|
||||||
data,
|
data,
|
||||||
|
isSecondary = false,
|
||||||
}: {
|
}: {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
|
isSecondary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
|
// Admissions rounds are phase-specific: an all-through school's Year 7
|
||||||
|
// round must never stand in for Reception on the primary tab (and vice
|
||||||
|
// versa) — beside pure primaries it reads as Reception odds.
|
||||||
|
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
|
||||||
|
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
|
||||||
const anyData = rows.some(Boolean);
|
const anyData = rows.some(Boolean);
|
||||||
const entryYear = rows.find(Boolean)?.year;
|
const entryYear = rows.find(Boolean)?.year;
|
||||||
const entryLabel = entryYear
|
const entryLabel = entryYear
|
||||||
@@ -28,7 +34,10 @@ export function CompareAdmissions({
|
|||||||
|
|
||||||
if (!anyData) {
|
if (!anyData) {
|
||||||
return (
|
return (
|
||||||
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
|
<Section
|
||||||
|
title="Getting a place"
|
||||||
|
how={`No ${roundLabel} admissions data is available for these schools yet.`}
|
||||||
|
>
|
||||||
<></>
|
<></>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
@@ -63,7 +72,9 @@ export function CompareAdmissions({
|
|||||||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No data</span>
|
<span className={s.small}>
|
||||||
|
We don't hold {roundLabel} admissions data for this school
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
@@ -104,15 +115,28 @@ export function CompareAdmissions({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const a = rows[i];
|
const a = rows[i];
|
||||||
const summary = summariseAdmissions(a);
|
const summary = summariseAdmissions(a);
|
||||||
|
const info = data[String(school.urn)]?.school_info;
|
||||||
|
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
|
||||||
|
const faith =
|
||||||
|
!!info?.religious_denomination &&
|
||||||
|
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
|
||||||
let text: string | null = null;
|
let text: string | null = null;
|
||||||
if (summary.firstPrefPct != null) {
|
if (summary.firstPrefPct != null) {
|
||||||
if (summary.firstPrefPct >= 100) {
|
if (selective) {
|
||||||
|
// Selective schools: the entrance test decides, whatever the
|
||||||
|
// offer percentage looks like — never the distance template.
|
||||||
|
text =
|
||||||
|
'Entry is by entrance test — the school is selective; distance and preference rank don’t decide places.';
|
||||||
|
} else if (summary.firstPrefPct >= 100) {
|
||||||
text = `Every family who put ${school.school_name} first got a place.`;
|
text = `Every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (summary.firstPrefPct >= 90) {
|
} else if (summary.firstPrefPct >= 90) {
|
||||||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (a?.oversubscribed) {
|
} else if (a?.oversubscribed) {
|
||||||
text =
|
text = isSecondary
|
||||||
'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
? faith
|
||||||
|
? 'More first-choice applications than places — check the school’s admission criteria (faith-based criteria may apply).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (catchment or distance often decides, but criteria vary).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||||||
} else {
|
} else {
|
||||||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
admissionsForPhase,
|
||||||
latestValues,
|
latestValues,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
summariseAdmissions,
|
summariseAdmissions,
|
||||||
@@ -145,7 +146,11 @@ export function CompareAtAGlance({
|
|||||||
|
|
||||||
<Measure label="Getting a place">
|
<Measure label="Getting a place">
|
||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
|
// Phase-matched round only — an all-through school's Year 7 round
|
||||||
|
// must not masquerade as Reception odds on the primary tab.
|
||||||
|
const summary = summariseAdmissions(
|
||||||
|
admissionsForPhase(data[String(school.urn)], isSecondary),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{summary.chip ? (
|
{summary.chip ? (
|
||||||
|
|||||||
@@ -108,14 +108,21 @@ function JudgementDetailCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyAreas: Array<[string, number | null]> = [
|
const legacyAreas: Array<[string, number | null | undefined]> = [
|
||||||
['Quality of education', ofsted.quality_of_education],
|
['Quality of education', ofsted.quality_of_education],
|
||||||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||||||
['Personal development', ofsted.personal_development],
|
['Personal development', ofsted.personal_development],
|
||||||
['Leadership & management', ofsted.leadership_management],
|
['Leadership & management', ofsted.leadership_management],
|
||||||
['Early years provision', ofsted.early_years_provision],
|
['Early years provision', ofsted.early_years_provision],
|
||||||
|
['Sixth form provision', ofsted.sixth_form_provision],
|
||||||
];
|
];
|
||||||
const published = legacyAreas.filter(([, grade]) => grade != null);
|
// Only real Ofsted grades (1–4) are judgements. The MI file uses sentinel
|
||||||
|
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
|
||||||
|
// must never render as a rating chip.
|
||||||
|
const published = legacyAreas.filter(
|
||||||
|
(entry): entry is [string, number] =>
|
||||||
|
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
|
||||||
|
);
|
||||||
|
|
||||||
if (published.length === 0) {
|
if (published.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -143,6 +143,41 @@ export interface AdmissionsSummary {
|
|||||||
interest: string | null;
|
interest: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the admissions round for the ACTIVE phase tab. An all-through school
|
||||||
|
* can carry only a Year 7 (Secondary) round — rendering that beside pure
|
||||||
|
* primaries' Reception rounds made 433-forms-for-173-places read as
|
||||||
|
* Reception odds. Rows matching the target phase win (latest year first);
|
||||||
|
* rows tagged with the OTHER phase are never substituted. Untagged rows
|
||||||
|
* (legacy data, no school_phase) are used only when no row carries a phase.
|
||||||
|
*/
|
||||||
|
export function admissionsForPhase(
|
||||||
|
data:
|
||||||
|
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
isSecondary: boolean,
|
||||||
|
): SchoolAdmissions | null {
|
||||||
|
if (!data) return null;
|
||||||
|
const rows: SchoolAdmissions[] = [
|
||||||
|
...(data.admissions_history ?? []),
|
||||||
|
...(data.admissions ? [data.admissions] : []),
|
||||||
|
];
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const target = isSecondary ? 'secondary' : 'primary';
|
||||||
|
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
|
||||||
|
|
||||||
|
const matching = rows
|
||||||
|
.filter((r) => r.school_phase?.toLowerCase() === target)
|
||||||
|
.sort(byYearDesc);
|
||||||
|
if (matching.length > 0) return matching[0];
|
||||||
|
|
||||||
|
const tagged = rows.some((r) => r.school_phase != null);
|
||||||
|
if (!tagged) return [...rows].sort(byYearDesc)[0];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function summariseAdmissions(
|
export function summariseAdmissions(
|
||||||
a: SchoolAdmissions | null | undefined,
|
a: SchoolAdmissions | null | undefined,
|
||||||
): AdmissionsSummary {
|
): AdmissionsSummary {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface OfstedInspection {
|
|||||||
quality_of_education: number | null;
|
quality_of_education: number | null;
|
||||||
behaviour_attitudes: number | null;
|
behaviour_attitudes: number | null;
|
||||||
personal_development: number | null;
|
personal_development: number | null;
|
||||||
|
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
|
||||||
|
sixth_form_provision?: number | null;
|
||||||
leadership_management: number | null;
|
leadership_management: number | null;
|
||||||
early_years_provision: number | null;
|
early_years_provision: number | null;
|
||||||
previous_overall: number | null;
|
previous_overall: number | null;
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ with DAG(
|
|||||||
|
|
||||||
dbt_build_ees = BashOperator(
|
dbt_build_ees = BashOperator(
|
||||||
task_id="dbt_build",
|
task_id="dbt_build",
|
||||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_typesense_ees = BashOperator(
|
sync_typesense_ees = BashOperator(
|
||||||
|
|||||||
Reference in New Issue
Block a user