Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8c396eb9 | ||
|
|
a102508ef1 | ||
|
|
684da611ff | ||
|
|
0186227ced | ||
|
|
d02a323cdc | ||
|
|
32f8a02862 | ||
|
|
7e3096c881 | ||
|
|
1f80571b1f | ||
|
|
3132f07af2 | ||
|
|
8a9ba30cc2 | ||
|
|
388b496cc5 | ||
|
|
15da060e4a | ||
|
|
5981916b82 | ||
|
|
a5b2296240 | ||
|
|
af23fbf27e | ||
|
|
22e9eb2d48 | ||
|
|
13253116c2 | ||
|
|
1b8adb91ee | ||
|
|
ca4fc647a5 | ||
|
|
261403bcd2 | ||
|
|
e4c8b86fe7 | ||
|
|
1c1df77961 | ||
|
|
993822d769 | ||
|
|
0b36eace9d | ||
|
|
de943ded19 | ||
|
|
ae6ef6860b | ||
|
|
4e316b4e38 | ||
|
|
733faefb98 | ||
|
|
5e370d09f1 |
@@ -606,17 +606,21 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"religious_denomination": latest.get("religious_denomination", ""),
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"has_sixth_form": latest.get("has_sixth_form"),
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
"nursery_provision": latest.get("nursery_provision"),
|
||||||
"status": latest.get("status"),
|
"status": latest.get("status"),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": latest.get("phase"),
|
"phase": latest.get("phase"),
|
||||||
# GIAS fields
|
# GIAS fields
|
||||||
"website": latest.get("website"),
|
"website": latest.get("website"),
|
||||||
|
"telephone": latest.get("telephone"),
|
||||||
"headteacher_name": latest.get("headteacher_name"),
|
"headteacher_name": latest.get("headteacher_name"),
|
||||||
"capacity": latest.get("capacity"),
|
"capacity": latest.get("capacity"),
|
||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
|
"county": latest.get("county"),
|
||||||
|
"parliamentary_constituency": latest.get("parliamentary_constituency"),
|
||||||
}.items()
|
}.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +811,10 @@ async def get_la_averages(request: Request):
|
|||||||
_KS2_NATIONAL_METRICS = [
|
_KS2_NATIONAL_METRICS = [
|
||||||
"rwm_expected_pct", "rwm_high_pct",
|
"rwm_expected_pct", "rwm_high_pct",
|
||||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||||
|
# Per-subject higher-standard nationals: reading/maths reach the "higher
|
||||||
|
# standard" in the tests; writing is teacher-assessed at "greater depth"
|
||||||
|
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
|
||||||
|
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
|
||||||
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
||||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
"reading_progress", "writing_progress", "maths_progress",
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
|
|||||||
+47
-1
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
|
|||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
|
s.telephone,
|
||||||
|
s.nursery_provision,
|
||||||
foi.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
foi.ofsted_date,
|
foi.ofsted_date,
|
||||||
foi.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
|
|||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
l.address_line2 AS address2,
|
l.address_line2 AS address2,
|
||||||
l.town,
|
l.town,
|
||||||
|
l.county,
|
||||||
|
l.parliamentary_constituency,
|
||||||
l.postcode,
|
l.postcode,
|
||||||
l.latitude,
|
l.latitude,
|
||||||
l.longitude,
|
l.longitude,
|
||||||
@@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
|||||||
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||||
|
|
||||||
|
# Fallback used when marts predate the optional GIAS/location detail columns
|
||||||
|
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
|
||||||
|
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
||||||
|
# present as NULL so the whole data load degrades gracefully instead of failing.
|
||||||
|
_EXTRA_OPTIONAL_COLUMNS = (
|
||||||
|
"telephone",
|
||||||
|
"nursery_provision",
|
||||||
|
"county",
|
||||||
|
"parliamentary_constituency",
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
||||||
|
for _col, _tbl in (
|
||||||
|
("telephone", "s"),
|
||||||
|
("nursery_provision", "s"),
|
||||||
|
("county", "l"),
|
||||||
|
("parliamentary_constituency", "l"),
|
||||||
|
):
|
||||||
|
_src = f"{_tbl}.{_col},"
|
||||||
|
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
||||||
|
f"expected {_src!r} to be present in _MAIN_QUERY before replacement"
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace(
|
||||||
|
_src, f"NULL AS {_col},"
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS)
|
||||||
|
|
||||||
_GIAS_CODE_COLUMN_NAMES = (
|
_GIAS_CODE_COLUMN_NAMES = (
|
||||||
"phase_code",
|
"phase_code",
|
||||||
"school_type_code",
|
"school_type_code",
|
||||||
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
|
|||||||
"admissions_policy_code",
|
"admissions_policy_code",
|
||||||
)
|
)
|
||||||
|
|
||||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports
|
||||||
|
# an undefined *qualified* column unquoted as "column l.county does not exist",
|
||||||
|
# so matching only the s. alias would miss dim_location columns (county,
|
||||||
|
# parliamentary_constituency) and defeat the graceful-degradation fallback.
|
||||||
|
_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist')
|
||||||
|
|
||||||
|
|
||||||
def _missing_column_name(exc: Exception) -> Optional[str]:
|
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||||
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
except Exception as exc2:
|
except Exception as exc2:
|
||||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
elif missing in _EXTRA_OPTIONAL_COLUMNS:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"marts.dim_school/dim_location is missing %s (pipeline hasn't "
|
||||||
|
"rebuilt the mart yet on this DB) — retrying without the "
|
||||||
|
"optional detail columns: %s",
|
||||||
|
missing, exc,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, engine)
|
||||||
|
except Exception as exc2:
|
||||||
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
|
return pd.DataFrame()
|
||||||
else:
|
else:
|
||||||
print(f"Warning: Could not load school data from marts: {exc}")
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_location_alias_prefixed():
|
||||||
|
# dim_location columns are selected via the `l.` alias; Postgres reports a
|
||||||
|
# missing qualified column unquoted (e.g. "column l.county does not exist").
|
||||||
|
# The matcher must strip any alias, not just `s.`, or the county /
|
||||||
|
# parliamentary_constituency fallback never triggers and the whole data
|
||||||
|
# load degrades to an empty DataFrame (zero schools) instead of NULLs.
|
||||||
|
assert (
|
||||||
|
_missing_column_name(_fake_exc("column l.county does not exist"))
|
||||||
|
== "county"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_missing_column_name(
|
||||||
|
_fake_exc("column l.parliamentary_constituency does not exist")
|
||||||
|
)
|
||||||
|
== "parliamentary_constituency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_column_name_no_match_returns_none():
|
def test_missing_column_name_no_match_returns_none():
|
||||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class _Ks2Row:
|
|||||||
year = LATEST
|
year = LATEST
|
||||||
rwm_expected_pct = 62.1
|
rwm_expected_pct = 62.1
|
||||||
gps_expected_pct = 72.0
|
gps_expected_pct = 72.0
|
||||||
|
# Per-subject higher-standard nationals — reading/maths reach the higher
|
||||||
|
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
|
||||||
|
reading_high_pct = 29.0
|
||||||
|
writing_gd_pct = 13.0
|
||||||
|
maths_high_pct = 24.0
|
||||||
|
|
||||||
|
|
||||||
class _Ks4Row:
|
class _Ks4Row:
|
||||||
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
|||||||
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
|
||||||
|
# The SATs chart compares each bar to its own benchmark, so the per-subject
|
||||||
|
# higher-standard / greater-depth nationals must reach the payload — not
|
||||||
|
# only the combined rwm_high_pct.
|
||||||
|
body = payload(_StubSession)
|
||||||
|
assert body["primary"]["reading_high_pct"] == 29.0
|
||||||
|
assert body["primary"]["writing_gd_pct"] == 13.0
|
||||||
|
assert body["primary"]["maths_high_pct"] == 24.0
|
||||||
|
|
||||||
|
|
||||||
def test_ks4_secondary_empty_when_mart_missing(payload):
|
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||||
# No computed stand-in: the UI labels national figures as official DfE
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
# data, so an empty mart must yield an empty secondary series.
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
|
|||||||
@@ -121,6 +121,51 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => {
|
||||||
|
const [urn] = await twoPrimaryUrns(page);
|
||||||
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const info = (await res.json()).school_info;
|
||||||
|
|
||||||
|
await page.goto(`/school/${urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Phonics, SEN-type breakdown and average class size were never populated by
|
||||||
|
// the backend — the sections have been removed, so the Phonics section (its
|
||||||
|
// own #phonics anchor) must no longer exist.
|
||||||
|
await expect(page.locator('#phonics')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Newly surfaced GIAS/location fields render when the record carries them.
|
||||||
|
const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||||||
|
if (ageMatch) {
|
||||||
|
await expect(page.getByText(`Ages ${ageMatch[1]}–${ageMatch[2]}`).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
if (info.telephone) {
|
||||||
|
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
if (info.parliamentary_constituency) {
|
||||||
|
await expect(page.getByText('Constituency:').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const [urn] = await twoPrimaryUrns(page);
|
||||||
|
await page.goto(`/school/${urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Collapsed by default on mobile/tablet …
|
||||||
|
const details = page.locator('#school-header-details');
|
||||||
|
await expect(details).toBeHidden();
|
||||||
|
const toggle = page.getByRole('button', { name: /show all details/i });
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
|
||||||
|
// … and the link reveals them (label flips to "Hide details").
|
||||||
|
await toggle.click();
|
||||||
|
await expect(details).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /hide details/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
||||||
// Detail views detected report cards via `framework`, which the API never
|
// Detail views detected report cards via `framework`, which the API never
|
||||||
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
||||||
@@ -175,8 +220,30 @@ test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not ju
|
|||||||
// The combined results section carries both phases.
|
// The combined results section carries both phases.
|
||||||
const results = page.locator('#results');
|
const results = page.locator('#results');
|
||||||
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
|
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
|
||||||
await expect(results.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2
|
await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
|
||||||
await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4
|
await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a special school is not shown as failing against the mainstream England average', async ({ page }) => {
|
||||||
|
// Special schools sit the same tests but very few pupils reach the mainstream
|
||||||
|
// "expected standard", so a "0.0% · −62 pts below England average" rendering
|
||||||
|
// portrays them as failing against a benchmark that doesn't fit. The results
|
||||||
|
// section drops the England comparison and explains the context instead.
|
||||||
|
const SP_URN = 101099; // Greenmead School — a community special school
|
||||||
|
const res = await page.request.get(`/api/schools/${SP_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const detail = await res.json();
|
||||||
|
test.skip(
|
||||||
|
!/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''),
|
||||||
|
'precondition: chosen URN must currently be a special school',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/school/${SP_URN}`);
|
||||||
|
const results = page.locator('#results');
|
||||||
|
// The special-school context note is shown…
|
||||||
|
await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 });
|
||||||
|
// …and the mainstream England-average comparison is dropped entirely.
|
||||||
|
await expect(results.getByText(/England avg/i)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||||
@@ -416,6 +483,18 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
|||||||
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admissions guide renders its key milestones', async ({ page }) => {
|
||||||
|
// Static content page — assert the guide loads and the load-bearing
|
||||||
|
// milestones parents rely on are present (dates are statutory, so these
|
||||||
|
// strings are stable invariants, not data-refresh-sensitive).
|
||||||
|
await page.goto('/admissions');
|
||||||
|
await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible();
|
||||||
|
// National Offer Day is the milestone the whole guide builds toward.
|
||||||
|
await expect(page.getByText(/National Offer Day/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('rankings page loads a populated table', async ({ page }) => {
|
test('rankings page loads a populated table', async ({ page }) => {
|
||||||
await page.goto('/rankings');
|
await page.goto('/rankings');
|
||||||
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* SpecialSchoolNote Component Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SpecialSchoolNote } from '@/components/SpecialSchoolNote';
|
||||||
|
|
||||||
|
describe('SpecialSchoolNote', () => {
|
||||||
|
it('renders nothing for a mainstream school', () => {
|
||||||
|
const { container } = render(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a special school as teaching pupils with SEND', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
|
||||||
|
expect(screen.getByText(/This is a special school/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/special educational needs/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
|
||||||
|
expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call alternative-provision pupils SEND', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
|
||||||
|
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -269,6 +269,44 @@ describe('isProposedToClose', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSpecialSchool', () => {
|
||||||
|
const { isSpecialSchool } = require('@/lib/utils');
|
||||||
|
|
||||||
|
it('matches every DfE special-school establishment type', () => {
|
||||||
|
for (const t of [
|
||||||
|
'Community special school',
|
||||||
|
'Foundation special school',
|
||||||
|
'Academy special converter',
|
||||||
|
'Academy special sponsor led',
|
||||||
|
'Non-maintained special school',
|
||||||
|
'Free schools special',
|
||||||
|
'Other independent special school',
|
||||||
|
]) {
|
||||||
|
expect(isSpecialSchool({ school_type: t })).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches pupil referral units and alternative provision', () => {
|
||||||
|
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
|
||||||
|
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match mainstream types (no false positives)', () => {
|
||||||
|
for (const t of [
|
||||||
|
'Academy converter',
|
||||||
|
'Community school',
|
||||||
|
'Voluntary aided school',
|
||||||
|
'Free schools',
|
||||||
|
'University technical college',
|
||||||
|
'Other independent school',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
expect(isSpecialSchool({ school_type: t })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shortName', () => {
|
describe('shortName', () => {
|
||||||
it('drops the trailing establishment-type words', () => {
|
it('drops the trailing establishment-type words', () => {
|
||||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ export default function RootLayout({
|
|||||||
data-performance="true"
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
|
{/* Heatmap / session recorder — same data-domains guard so staging
|
||||||
|
(same image, different host) never feeds the heatmap. */}
|
||||||
|
<Script
|
||||||
|
src="https://analytics.schoolcompare.co.uk/recorder.js"
|
||||||
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||||
|
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||||
<div className="noise-overlay" />
|
<div className="noise-overlay" />
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -191,8 +191,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
senDetail={sen_detail ?? null}
|
|
||||||
phonics={phonics ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -205,8 +203,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
senDetail={sen_detail ?? null}
|
|
||||||
phonics={phonics ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -442,6 +442,58 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Easy-to-miss callouts ──────────────────────────── */
|
||||||
|
|
||||||
|
.calloutGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
||||||
|
border: 1px solid rgba(201, 162, 39, 0.28);
|
||||||
|
border-left: 3px solid var(--accent-gold, #c9a227);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-gold, #c9a227);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutText {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutTitle {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-gold-text, #7a6800);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutBody {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Tips ───────────────────────────────────────────── */
|
/* ─── Tips ───────────────────────────────────────────── */
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface Step {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
highlight?: 'deadline' | 'offer';
|
highlight?: 'deadline' | 'offer';
|
||||||
|
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
|
||||||
|
rather than buried in the body prose. */
|
||||||
|
callouts?: { title: string; body: string }[];
|
||||||
/* How SchoolCompare helps at this stage of the journey */
|
/* How SchoolCompare helps at this stage of the journey */
|
||||||
tool?: { label: string; href: string };
|
tool?: { label: string; href: string };
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,16 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Check entry criteria',
|
title: 'Check entry criteria',
|
||||||
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
||||||
|
callouts: [
|
||||||
|
{
|
||||||
|
title: 'Selective schools register earlier',
|
||||||
|
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faith schools may need an extra form',
|
||||||
|
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
|
||||||
|
},
|
||||||
|
],
|
||||||
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,14 +86,14 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
date: '31 October',
|
date: '31 October',
|
||||||
title: 'Application deadline',
|
title: 'Application deadline',
|
||||||
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
|
body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
|
||||||
highlight: 'deadline',
|
highlight: 'deadline',
|
||||||
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '1 March',
|
date: '1 March',
|
||||||
title: 'National Offer Day',
|
title: 'National Offer Day',
|
||||||
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
|
||||||
highlight: 'offer',
|
highlight: 'offer',
|
||||||
tool: { label: 'Look up your allocated school', href: '/' },
|
tool: { label: 'Look up your allocated school', href: '/' },
|
||||||
},
|
},
|
||||||
@@ -101,6 +114,12 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Research entry criteria',
|
title: 'Research entry criteria',
|
||||||
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
||||||
|
callouts: [
|
||||||
|
{
|
||||||
|
title: 'Faith schools may need an extra form',
|
||||||
|
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
|
||||||
|
},
|
||||||
|
],
|
||||||
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,7 +163,7 @@ interface Tip {
|
|||||||
const TIPS: Tip[] = [
|
const TIPS: Tip[] = [
|
||||||
{
|
{
|
||||||
heading: 'Equal preference rule',
|
heading: 'Equal preference rule',
|
||||||
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
|
body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Late applications go to the back',
|
heading: 'Late applications go to the back',
|
||||||
@@ -307,6 +326,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.callouts && step.callouts.length > 0 && (
|
||||||
|
<div className={styles.calloutGroup}>
|
||||||
|
{step.callouts.map((c) => (
|
||||||
|
<div key={c.title} className={styles.callout}>
|
||||||
|
<span className={styles.calloutIcon} aria-hidden="true">!</span>
|
||||||
|
<div className={styles.calloutText}>
|
||||||
|
<div className={styles.calloutTitle}>{c.title}</div>
|
||||||
|
<p className={styles.calloutBody}>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{step.tool && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
@@ -355,6 +387,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.callouts && step.callouts.length > 0 && (
|
||||||
|
<div className={styles.calloutGroup}>
|
||||||
|
{step.callouts.map((c) => (
|
||||||
|
<div key={c.title} className={styles.callout}>
|
||||||
|
<span className={styles.calloutIcon} aria-hidden="true">!</span>
|
||||||
|
<div className={styles.calloutText}>
|
||||||
|
<div className={styles.calloutTitle}>{c.title}</div>
|
||||||
|
<p className={styles.calloutBody}>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{step.tool && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
|||||||
<div className={styles.editorialGrid}>
|
<div className={styles.editorialGrid}>
|
||||||
<div className={styles.editorialText}>
|
<div className={styles.editorialText}>
|
||||||
<div className={styles.editorialKicker}>About school data</div>
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education publishes
|
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||||
demographics — each in its own table, each with its own jargon.
|
and demographics — each in its own table, each with its own jargon.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
40-page inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.factbox}>
|
<div className={styles.factbox}>
|
||||||
|
|||||||
@@ -720,10 +720,13 @@
|
|||||||
.miniRowHead {
|
.miniRowHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.48rem;
|
align-items: baseline;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.44rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted, #6d685f);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniRowHead strong {
|
.miniRowHead strong {
|
||||||
@@ -869,28 +872,77 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedBadge {
|
.ofstedFramework {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(45, 125, 125, 0.12);
|
background: rgba(90, 58, 110, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: #5a3a6e;
|
||||||
font-size: 0.55rem;
|
font-size: 0.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict {
|
.rcList {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
display: flex;
|
||||||
font-size: 1.05rem;
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcArea {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-secondary, #4a453d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcChip {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.52rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
letter-spacing: 0.03em;
|
||||||
color: var(--text-primary, #1a1612);
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict em {
|
.rcChipGood {
|
||||||
|
background: rgba(45, 125, 125, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--accent-teal, #2d7d7d);
|
||||||
font-style: normal;
|
}
|
||||||
|
|
||||||
|
.rcChipNeutral {
|
||||||
|
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
|
||||||
|
color: var(--accent-gold-text, #7a6800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
|
||||||
|
so it sits below the five-point list behind a divider with its own chip. */
|
||||||
|
.rcSafeguarding {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px dashed var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcSep {
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcChipMet {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedMeta {
|
.ofstedMeta {
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
<div className={styles.heroSection}>
|
<div className={styles.heroSection}>
|
||||||
<span className={styles.heroEyebrow}>
|
<span className={styles.heroEyebrow}>
|
||||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
Updated with 2026/2027 admissions results
|
Updated for the 2026/27 admissions round
|
||||||
</span>
|
</span>
|
||||||
<h1 className={styles.heroTitle}>
|
<h1 className={styles.heroTitle}>
|
||||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
export function HowItWorksSection() {
|
export function HowItWorksSection() {
|
||||||
|
// "exc" is the school's above-expected result. DfE names this differently
|
||||||
|
// by subject: "higher standard" for the reading and maths tests, "greater
|
||||||
|
// depth" for teacher-assessed writing. "nat" is the national expected %.
|
||||||
const miniCascade = [
|
const miniCascade = [
|
||||||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
|
||||||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||||
];
|
];
|
||||||
const compareRows = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
|
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
];
|
||||||
|
// Report card (Ofsted's framework from Nov 2025): one rating per area of
|
||||||
|
// school life on a five-point scale. Safeguarding is judged separately, as a
|
||||||
|
// binary met/not-met outcome — kept out of this list and shown on its own.
|
||||||
|
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
|
||||||
|
{ area: 'Achievement', grade: 'Strong', good: true },
|
||||||
|
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
|
||||||
|
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseBlock}>
|
<div className={styles.hiwPhaseBlock}>
|
||||||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||||
<div className={styles.miniCascade}>
|
<div className={styles.miniCascade}>
|
||||||
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
|
||||||
<div key={subj} className={styles.miniCascadeCol}>
|
<div key={subj} className={styles.miniCascadeCol}>
|
||||||
<div className={styles.miniSubj}>{subj}</div>
|
<div className={styles.miniSubj}>{subj}</div>
|
||||||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||||
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
|
||||||
<div className={styles.miniTrack}>
|
<div className={styles.miniTrack}>
|
||||||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||||
<div className={styles.att8Row}>
|
<div className={styles.att8Row}>
|
||||||
<div className={styles.att8BarWrap}>
|
<div className={styles.att8BarWrap}>
|
||||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
|
||||||
<div className={styles.att8Track}>
|
<div className={styles.att8Track}>
|
||||||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
|
||||||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.att8Score}>
|
<div className={styles.att8Score}>
|
||||||
<div className={styles.att8Value}>62.4</div>
|
<div className={styles.att8Value}>54.8</div>
|
||||||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
<div className={styles.att8Delta}>+8.9 vs national</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.ofstedBullet} />
|
<span className={styles.ofstedBullet} />
|
||||||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
|
||||||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
<div className={styles.rcList}>
|
||||||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
{reportCard.map(({ area, grade, good }) => (
|
||||||
|
<div key={area} className={styles.rcRow}>
|
||||||
|
<span className={styles.rcArea}>{area}</span>
|
||||||
|
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rcSafeguarding}>
|
||||||
|
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
|
||||||
|
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Judgement</div>
|
<div className={styles.hiwStep}>Judgement</div>
|
||||||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date — and, for schools not yet reinspected, the older overall grade they still carry.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function RankingsView({
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>School Rankings</h1>
|
<h1>School Rankings</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Top-performing schools by {metricLabel.toLowerCase()}
|
Schools ranked by {metricLabel.toLowerCase()}
|
||||||
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -143,7 +143,10 @@ export function RankingsView({
|
|||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
{isProgressScore && (
|
{isProgressScore && (
|
||||||
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
|
<p className={styles.progressHint}>
|
||||||
|
Progress scores: 0 = national average. Positive = above average.
|
||||||
|
{isPrimary && ' KS2 progress isn’t published for 2023/24 or 2024/25 (there’s no key stage 1 baseline) — pick an earlier year to rank by it.'}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -218,7 +221,11 @@ export function RankingsView({
|
|||||||
{rankings.length === 0 ? (
|
{rankings.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No rankings found"
|
title="No rankings found"
|
||||||
message="Try selecting a different metric, area, or year."
|
message={
|
||||||
|
isPrimary && isProgressScore
|
||||||
|
? 'KS2 progress scores aren’t published for the most recent years (2023/24 and 2024/25) because there’s no key stage 1 baseline. Select an earlier year to see progress rankings.'
|
||||||
|
: 'Try selecting a different metric, area, or year.'
|
||||||
|
}
|
||||||
action={{
|
action={{
|
||||||
label: 'Clear filters',
|
label: 'Clear filters',
|
||||||
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||||
|
|||||||
@@ -44,30 +44,46 @@
|
|||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary, #f3ede4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── National average marker ── */
|
/* ── Per-bar national average marker ──
|
||||||
.natLine {
|
Each bar compares against its own benchmark (expected vs higher standard /
|
||||||
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
|
as one line spanning both bars. */
|
||||||
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -3px;
|
||||||
height: calc(100% - 20px);
|
bottom: -3px;
|
||||||
width: 1.5px;
|
width: 2px;
|
||||||
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
|
transform: translateX(-50%);
|
||||||
z-index: 2;
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.natPill {
|
.natTick::before {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -3px;
|
||||||
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral, #e07256);
|
||||||
color: #fff;
|
}
|
||||||
|
|
||||||
|
.barHeaderRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natLabel {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.1rem 0.35rem;
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
border-radius: 4px;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
letter-spacing: 0.03em;
|
||||||
z-index: 3;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bar rows ── */
|
/* ── Bar rows ── */
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface SubjectData {
|
|||||||
expectedPct: number | null;
|
expectedPct: number | null;
|
||||||
exceedingPct: number | null;
|
exceedingPct: number | null;
|
||||||
nationalExpectedPct: number | null;
|
nationalExpectedPct: number | null;
|
||||||
|
/** National higher-standard / greater-depth benchmark for the exceeding
|
||||||
|
* bar — a different figure from the expected national. */
|
||||||
|
nationalExceedingPct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SatsChartProps {
|
interface SatsChartProps {
|
||||||
@@ -17,11 +20,45 @@ interface SatsChartProps {
|
|||||||
const RULER_TICKS = [0, 25, 50, 75, 100];
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||||
const GRIDLINE_POSITIONS = [25, 50, 75];
|
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||||
|
|
||||||
|
/** One results bar with a national marker positioned on its own track, so the
|
||||||
|
* expected and exceeding bars each compare against their own benchmark. */
|
||||||
|
function BarRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
national,
|
||||||
|
barRef,
|
||||||
|
barClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
national: number | null;
|
||||||
|
barRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
barClass: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div className={styles.barHeader}>
|
||||||
|
<span className={styles.barLabelSuffix}>{label}</span>
|
||||||
|
<span className={styles.barHeaderRight}>
|
||||||
|
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
|
||||||
|
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.barTrack}>
|
||||||
|
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
|
||||||
|
{national != null && (
|
||||||
|
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||||
const expectedRef = useRef<HTMLDivElement>(null);
|
const expectedRef = useRef<HTMLDivElement>(null);
|
||||||
const exceedingRef = useRef<HTMLDivElement>(null);
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
|
||||||
|
|
||||||
// Animate bars on mount
|
// Animate bars on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* National average marker */}
|
{/* Bars — each carries its own national marker */}
|
||||||
{nationalExpectedPct != null && (
|
|
||||||
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
|
||||||
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
<div className={styles.barGroup}>
|
<div className={styles.barGroup}>
|
||||||
{expectedPct != null && (
|
{expectedPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Expected"
|
||||||
<span className={styles.barLabelSuffix}>Expected</span>
|
value={expectedPct}
|
||||||
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
national={nationalExpectedPct}
|
||||||
</div>
|
barRef={expectedRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExpected}
|
||||||
<div
|
/>
|
||||||
ref={expectedRef}
|
|
||||||
className={`${styles.bar} ${styles.barExpected}`}
|
|
||||||
data-width={expectedPct}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{exceedingPct != null && (
|
{exceedingPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Exceeding"
|
||||||
<span className={styles.barLabelSuffix}>Exceeding</span>
|
value={exceedingPct}
|
||||||
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
national={nationalExceedingPct}
|
||||||
</div>
|
barRef={exceedingRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExceeding}
|
||||||
<div
|
/>
|
||||||
ref={exceedingRef}
|
|
||||||
className={`${styles.bar} ${styles.barExceeding}`}
|
|
||||||
data-width={exceedingPct}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
National average
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -197,6 +197,22 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Show all details" reveal — only rendered on mobile/tablet, where the
|
||||||
|
header details block is collapsed below the fold. Hidden on desktop. */
|
||||||
|
.detailsToggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */
|
/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */
|
||||||
.genderSplitValue {
|
.genderSplitValue {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -855,7 +871,10 @@
|
|||||||
/* Charts Section */
|
/* Charts Section */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* Taller on desktop so the trend lines have vertical room to separate
|
||||||
|
and read clearly. Mobile overrides this to height:auto below (the
|
||||||
|
max-width:768px query), so this only affects desktop. */
|
||||||
|
height: 380px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1059,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||||
|
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||||
|
single- and double-line labels put their chips on the same baseline;
|
||||||
|
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||||
|
padding and min-height regardless of how many lines its text wraps to. */
|
||||||
|
.gradeGrid .metricCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 0.75rem;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricLabel {
|
||||||
|
min-height: 2.6em;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricValue {
|
||||||
|
margin-top: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 2.6em;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
@@ -1167,14 +1221,24 @@
|
|||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary header info (headteacher, website, pupil count, trust)
|
/* Secondary header info (headteacher, website, pupil count, trust,
|
||||||
isn't needed above the fold on phones — pupil count lives in the
|
contact, area) isn't needed above the fold on phones/tablets, so it's
|
||||||
Pupils & Inclusion section, website is one scroll away. Reclaim
|
collapsed by default and revealed on demand via the "Show all details"
|
||||||
the ~3 vertical lines so the actual metrics surface sooner. */
|
link — reclaiming the vertical space so the metrics surface sooner. */
|
||||||
|
.detailsToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.headerDetails {
|
.headerDetails {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerDetailsOpen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.metricsGrid {
|
.metricsGrid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import { MetricTooltip } from './MetricTooltip';
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas,
|
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
const PerformanceChart = dynamic(
|
||||||
@@ -67,15 +68,13 @@ interface SchoolDetailViewProps {
|
|||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
senDetail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SchoolDetailView({
|
export function SchoolDetailView({
|
||||||
schoolInfo, yearlyData, absenceData,
|
schoolInfo, yearlyData, absenceData,
|
||||||
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
ofsted, census, admissions, admissionsHistory, deprivation, finance,
|
||||||
}: SchoolDetailViewProps) {
|
}: SchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
@@ -100,6 +99,9 @@ export function SchoolDetailView({
|
|||||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||||
// "All ▾" jump menu listing every section.
|
// "All ▾" jump menu listing every section.
|
||||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||||
|
// Header details (headteacher, contact, trust, area) collapse behind a
|
||||||
|
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
// Back returns to wherever the user came from; deep-links (no in-app history)
|
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||||||
// fall back to search so the button never dead-ends or leaves the site.
|
// fall back to search so the button never dead-ends or leaves the site.
|
||||||
@@ -160,14 +162,12 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||||||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||||||
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
|
||||||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||||||
const isPrimary = !isSecondary;
|
const isPrimary = !isSecondary;
|
||||||
// Primary-stage content shows for pure-primary AND all-through schools.
|
|
||||||
const showPrimaryContent = isPrimary || isAllThrough;
|
|
||||||
|
|
||||||
// National averages (fetched dynamically so they stay current)
|
// National averages (fetched dynamically so they stay current)
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
@@ -219,11 +219,9 @@ export function SchoolDetailView({
|
|||||||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||||||
|| (latestResults?.eal_pct != null)
|
|| (latestResults?.eal_pct != null)
|
||||||
|| (latestResults?.sen_support_pct != null)
|
|| (latestResults?.sen_support_pct != null)
|
||||||
|| senDetail != null
|
|
||||||
|| hasGenderSplit;
|
|| hasGenderSplit;
|
||||||
|
|
||||||
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
const hasSchoolLife = absenceData != null;
|
||||||
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
|
||||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||||
@@ -233,6 +231,27 @@ export function SchoolDetailView({
|
|||||||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||||||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||||||
|
// reach the mainstream "expected standard", so a 0% headline and an England
|
||||||
|
// comparison portray them as failing against a benchmark that doesn't fit.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||||||
|
// special/suppressed signature) is a placeholder, not a real result. This
|
||||||
|
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||||||
|
// combined (some pupils met individual subjects but not all three) stays
|
||||||
|
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||||||
|
// to form such a signature, so KS4 keys off establishment type only — a
|
||||||
|
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||||||
|
const ks2Placeholder = latestResults != null
|
||||||
|
&& latestResults.rwm_expected_pct === 0
|
||||||
|
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||||||
|
// Whether to drop the England-average deltas / national markers / "below"
|
||||||
|
// framing on the attainment measures.
|
||||||
|
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||||||
|
const suppressKs4Comparison = isSpecial;
|
||||||
|
|
||||||
// Build section nav items dynamically — only sections with data.
|
// Build section nav items dynamically — only sections with data.
|
||||||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||||||
// sections — results, admissions, inclusion, history — sit near the top,
|
// sections — results, admissions, inclusion, history — sit near the top,
|
||||||
@@ -243,7 +262,6 @@ export function SchoolDetailView({
|
|||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
|
||||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
@@ -336,6 +354,15 @@ export function SchoolDetailView({
|
|||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.age_range && (
|
||||||
|
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.nursery_provision && (
|
||||||
|
<span className={styles.metaItem}>Nursery</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.has_sixth_form && (
|
||||||
|
<span className={styles.metaItem}>Sixth form</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isProposedToClose(schoolInfo) && (
|
{isProposedToClose(schoolInfo) && (
|
||||||
<div className={styles.closingStrip} role="note">
|
<div className={styles.closingStrip} role="note">
|
||||||
@@ -360,7 +387,20 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className={styles.headerDetails}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.detailsToggle}
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
aria-controls="school-header-details"
|
||||||
|
onClick={() => setDetailsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||||||
|
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="school-header-details"
|
||||||
|
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||||||
|
>
|
||||||
{schoolInfo.headteacher_name && (
|
{schoolInfo.headteacher_name && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||||
@@ -394,6 +434,32 @@ export function SchoolDetailView({
|
|||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.telephone && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Phone:</strong>{' '}
|
||||||
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||||
|
{schoolInfo.telephone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.religious_denomination && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Religious character:</strong>{' '}
|
||||||
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||||
|
? 'None'
|
||||||
|
: schoolInfo.religious_denomination}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.county && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>County:</strong> {schoolInfo.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.parliamentary_constituency && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions} ref={heroActionsRef}>
|
<div className={styles.actions} ref={heroActionsRef}>
|
||||||
@@ -555,7 +621,7 @@ export function SchoolDetailView({
|
|||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -601,7 +667,7 @@ export function SchoolDetailView({
|
|||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{oeifAreas.map(({ label, value }) => (
|
{oeifAreas.map(({ label, value }) => (
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
@@ -624,13 +690,22 @@ export function SchoolDetailView({
|
|||||||
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
{isAllThrough
|
{isSpecial
|
||||||
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
? (isSecondary
|
||||||
: isSecondary
|
? 'GCSE results for Year 11 pupils.'
|
||||||
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||||||
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
: isAllThrough
|
||||||
|
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||||
|
: isSecondary
|
||||||
|
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||||||
|
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Explains up front why the England comparison is dropped below, so
|
||||||
|
a 0% headline never reads as a failing grade against a benchmark
|
||||||
|
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
|
||||||
|
<SpecialSchoolNote school={schoolInfo} />
|
||||||
|
|
||||||
{/* ── Primary / KS2 content ── */}
|
{/* ── Primary / KS2 content ── */}
|
||||||
{hasKS2Results && (
|
{hasKS2Results && (
|
||||||
<>
|
<>
|
||||||
@@ -646,7 +721,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_expected_pct}
|
value={latestResults.rwm_expected_pct}
|
||||||
baseline={primaryAvg.rwm_expected_pct}
|
baseline={primaryAvg.rwm_expected_pct}
|
||||||
@@ -655,7 +730,7 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -668,7 +743,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_high_pct)}
|
{formatPercentage(latestResults.rwm_high_pct)}
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_high_pct}
|
value={latestResults.rwm_high_pct}
|
||||||
baseline={primaryAvg.rwm_high_pct}
|
baseline={primaryAvg.rwm_high_pct}
|
||||||
@@ -677,14 +752,15 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{latestResults.rwm_expected_pct != null &&
|
{!suppressKs2Comparison &&
|
||||||
|
latestResults.rwm_expected_pct != null &&
|
||||||
latestResults.reading_expected_pct != null &&
|
latestResults.reading_expected_pct != null &&
|
||||||
latestResults.writing_expected_pct != null &&
|
latestResults.writing_expected_pct != null &&
|
||||||
latestResults.maths_expected_pct != null && (
|
latestResults.maths_expected_pct != null && (
|
||||||
@@ -708,28 +784,38 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SatsChart
|
{/* All-zero placeholder rows (special / suppressed) would render as
|
||||||
subjects={[
|
three empty bars against the national markers — misleading, so
|
||||||
{
|
skip the chart. For a special school with some non-zero
|
||||||
name: 'Reading',
|
subjects, keep the bars but drop the national markers. */}
|
||||||
expectedPct: latestResults.reading_expected_pct,
|
{!ks2Placeholder && (
|
||||||
exceedingPct: latestResults.reading_high_pct,
|
<SatsChart
|
||||||
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
subjects={[
|
||||||
},
|
{
|
||||||
{
|
name: 'Reading',
|
||||||
name: 'Writing',
|
expectedPct: latestResults.reading_expected_pct,
|
||||||
expectedPct: latestResults.writing_expected_pct,
|
exceedingPct: latestResults.reading_high_pct,
|
||||||
exceedingPct: latestResults.writing_high_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||||
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Maths',
|
name: 'Writing',
|
||||||
expectedPct: latestResults.maths_expected_pct,
|
expectedPct: latestResults.writing_expected_pct,
|
||||||
exceedingPct: latestResults.maths_high_pct,
|
exceedingPct: latestResults.writing_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||||||
},
|
// Writing's higher level is teacher-assessed "greater depth".
|
||||||
]}
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
|
||||||
/>
|
},
|
||||||
|
{
|
||||||
|
name: 'Maths',
|
||||||
|
expectedPct: latestResults.maths_expected_pct,
|
||||||
|
exceedingPct: latestResults.maths_high_pct,
|
||||||
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress scores row */}
|
{/* Progress scores row */}
|
||||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||||
@@ -786,7 +872,7 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="attainment_8_score" />
|
<MetricTooltip metricKey="attainment_8_score" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +896,7 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -822,7 +908,7 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -887,6 +973,15 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
|
read these as the whole-school figures. */}
|
||||||
|
{isAllThrough && admissions.school_phase && (
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
{/* This-year Q&A */}
|
{/* This-year Q&A */}
|
||||||
@@ -1033,29 +1128,6 @@ export function SchoolDetailView({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{senDetail && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle}>Types of additional needs supported</h3>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
What proportion of pupils with additional needs have each type of support need.
|
|
||||||
</p>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
{[
|
|
||||||
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
|
|
||||||
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
|
|
||||||
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
|
|
||||||
{ label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct },
|
|
||||||
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
|
|
||||||
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
|
|
||||||
].filter(n => n.pct != null).map(({ label, pct }) => (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
|
||||||
<div className={styles.metricValue}>{pct}%</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1076,8 +1148,8 @@ export function SchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={false}
|
isSecondary={false}
|
||||||
nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
|
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1090,8 +1162,8 @@ export function SchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={true}
|
isSecondary={true}
|
||||||
nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
|
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1103,9 +1175,9 @@ export function SchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={isSecondary}
|
isSecondary={isSecondary}
|
||||||
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||||
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1180,41 +1252,11 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
|
||||||
{hasPhonics && showPrimaryContent && phonics && (
|
|
||||||
<section id="phonics" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
Phonics is a key early reading skill. Children are tested at the end of Year 1.
|
|
||||||
</p>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Passed the phonics check</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
|
|
||||||
<div className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
|
|
||||||
</div>
|
|
||||||
{phonics.year2_phonics_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Year 2 pupils who retook and passed</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* School Life */}
|
{/* School Life */}
|
||||||
{hasSchoolLife && (
|
{hasSchoolLife && (
|
||||||
<section id="school-life" className={styles.card}>
|
<section id="school-life" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>School Life</h2>
|
<h2 className={styles.sectionTitle}>School Life</h2>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{census?.class_size_avg != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Average class size</div>
|
|
||||||
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
|
|
||||||
<div className={styles.metricHint}>Average number of pupils per class</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{absenceData?.overall_absence_rate != null && (
|
{absenceData?.overall_absence_rate != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.metricLabel}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SchoolRow.module.css';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
interface SchoolRowProps {
|
interface SchoolRowProps {
|
||||||
@@ -38,10 +38,23 @@ export function SchoolRow({
|
|||||||
school.religious_denomination &&
|
school.religious_denomination &&
|
||||||
school.religious_denomination !== 'Does not apply';
|
school.religious_denomination !== 'Does not apply';
|
||||||
|
|
||||||
// vs-national delta
|
// A placeholder all-zero row (every subject 0 — a special/suppressed
|
||||||
|
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
|
||||||
|
// so its figure is hidden. A genuine 0% combined (some pupils met individual
|
||||||
|
// subjects but not all three) is NOT all-zero and stays shown.
|
||||||
|
const rwmPlaceholder =
|
||||||
|
school.rwm_expected_pct === 0 &&
|
||||||
|
(school.reading_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.writing_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.maths_expected_pct ?? 0) === 0;
|
||||||
|
// The school's OWN figure and its year-over-year trend are same-school
|
||||||
|
// measures — shown whenever there's a real value, special schools included.
|
||||||
|
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
||||||
|
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||||
|
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||||
const rwmDelta =
|
const rwmDelta =
|
||||||
school.rwm_expected_pct != null && nationalAvgRwm != null
|
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
|
||||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleCompareClick = () => {
|
const handleCompareClick = () => {
|
||||||
@@ -87,9 +100,9 @@ export function SchoolRow({
|
|||||||
<div className={styles.line3}>
|
<div className={styles.line3}>
|
||||||
<span className={styles.stat}>
|
<span className={styles.stat}>
|
||||||
<strong className={styles.statValue}>
|
<strong className={styles.statValue}>
|
||||||
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||||
</strong>
|
</strong>
|
||||||
{school.prev_rwm_expected_pct != null && (
|
{showRwmValue && school.prev_rwm_expected_pct != null && (
|
||||||
<span
|
<span
|
||||||
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
||||||
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
||||||
|
|||||||
@@ -207,6 +207,22 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Show all details" reveal — only rendered on mobile/tablet, where the
|
||||||
|
header details block is collapsed below the fold. Hidden on desktop. */
|
||||||
|
.detailsToggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -490,7 +506,10 @@
|
|||||||
/* ── Charts & Map ────────────────────────────────────── */
|
/* ── Charts & Map ────────────────────────────────────── */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* Taller on desktop so the trend lines have vertical room to separate
|
||||||
|
and read clearly. Mobile overrides this to height:auto below (the
|
||||||
|
max-width:768px query), so this only affects desktop. */
|
||||||
|
height: 380px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +642,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||||
|
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||||
|
single- and double-line labels put their chips on the same baseline;
|
||||||
|
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||||
|
padding and min-height regardless of how many lines its text wraps to. */
|
||||||
|
.gradeGrid .metricCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 0.75rem;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricLabel {
|
||||||
|
min-height: 2.6em;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricValue {
|
||||||
|
margin-top: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 2.6em;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
@@ -1028,7 +1082,18 @@
|
|||||||
padding: 0.1rem 0.375rem;
|
padding: 0.1rem 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed below the fold on phones/tablets; revealed via "Show all
|
||||||
|
details" so the metrics surface sooner. */
|
||||||
|
.detailsToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.headerDetails {
|
.headerDetails {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetailsOpen {
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ const PerformanceChart = dynamic(
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
@@ -67,15 +68,13 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
senDetail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
export function SecondarySchoolDetailView({
|
||||||
schoolInfo, yearlyData,
|
schoolInfo, yearlyData,
|
||||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
ofsted, census, admissions, deprivation, finance, absenceData,
|
||||||
}: SecondarySchoolDetailViewProps) {
|
}: SecondarySchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Hero map — the "View on map" link opens its fullscreen view.
|
// Hero map — the "View on map" link opens its fullscreen view.
|
||||||
@@ -84,6 +83,8 @@ export function SecondarySchoolDetailView({
|
|||||||
const isInComparison = isSelected(schoolInfo.urn);
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<string>('');
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
|
// Header details collapse behind a "Show all details" link on mobile/tablet.
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
@@ -107,6 +108,16 @@ export function SecondarySchoolDetailView({
|
|||||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||||
const hasResults = latestResults?.attainment_8_score != null;
|
const hasResults = latestResults?.attainment_8_score != null;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||||
|
// so their headline attainment is far below the mainstream average by design.
|
||||||
|
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||||
|
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||||
|
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||||
|
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||||
|
// mainstream school still shows its real value and comparison.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
const suppressComparison = isSpecial;
|
||||||
|
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
if (policy.includes('selective')) return 'Selective';
|
if (policy.includes('selective')) return 'Selective';
|
||||||
@@ -237,6 +248,9 @@ export function SecondarySchoolDetailView({
|
|||||||
{schoolInfo.age_range && (
|
{schoolInfo.age_range && (
|
||||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.nursery_provision && (
|
||||||
|
<span className={styles.badge}>Nursery</span>
|
||||||
|
)}
|
||||||
{hasSixthForm && (
|
{hasSixthForm && (
|
||||||
<span className={styles.badge}>Sixth form</span>
|
<span className={styles.badge}>Sixth form</span>
|
||||||
)}
|
)}
|
||||||
@@ -269,7 +283,20 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className={styles.headerDetails}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.detailsToggle}
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
aria-controls="school-header-details"
|
||||||
|
onClick={() => setDetailsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||||||
|
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="school-header-details"
|
||||||
|
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||||||
|
>
|
||||||
{schoolInfo.headteacher_name && (
|
{schoolInfo.headteacher_name && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||||
@@ -299,6 +326,32 @@ export function SecondarySchoolDetailView({
|
|||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.telephone && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Phone:</strong>{' '}
|
||||||
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||||
|
{schoolInfo.telephone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.religious_denomination && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Religious character:</strong>{' '}
|
||||||
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||||
|
? 'None'
|
||||||
|
: schoolInfo.religious_denomination}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.county && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>County:</strong> {schoolInfo.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.parliamentary_constituency && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
@@ -363,7 +416,7 @@ export function SecondarySchoolDetailView({
|
|||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -408,7 +461,7 @@ export function SecondarySchoolDetailView({
|
|||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{oeifAreas.map(({ label, value }) => (
|
{oeifAreas.map(({ label, value }) => (
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
@@ -425,7 +478,7 @@ export function SecondarySchoolDetailView({
|
|||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
From September 2024, Ofsted no longer gives a single overall grade.
|
From September 2024, Ofsted no longer gives a single overall grade.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||||
@@ -452,9 +505,11 @@ export function SecondarySchoolDetailView({
|
|||||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
GCSE results for Year 11 pupils. England averages shown for comparison.
|
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SpecialSchoolNote school={schoolInfo} />
|
||||||
|
|
||||||
{p8Suspended && (
|
{p8Suspended && (
|
||||||
<div className={styles.p8Banner}>
|
<div className={styles.p8Banner}>
|
||||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||||
@@ -472,7 +527,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{latestResults.attainment_8_score.toFixed(1)}
|
{latestResults.attainment_8_score.toFixed(1)}
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.attainment_8_score}
|
value={latestResults.attainment_8_score}
|
||||||
baseline={secondaryAvg.attainment_8_score}
|
baseline={secondaryAvg.attainment_8_score}
|
||||||
@@ -481,7 +536,7 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -512,7 +567,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_strong_pass_pct}
|
value={latestResults.english_maths_strong_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||||
@@ -521,7 +576,7 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -534,7 +589,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_standard_pass_pct}
|
value={latestResults.english_maths_standard_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||||
@@ -543,15 +598,17 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attainment 8 visual bar (0–80 scale) */}
|
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||||
{latestResults.attainment_8_score != null && (
|
"school vs national", so it's dropped for special schools where
|
||||||
|
that comparison isn't meaningful. */}
|
||||||
|
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||||
<div className={styles.att8Viz}>
|
<div className={styles.att8Viz}>
|
||||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||||
<div className={styles.att8VizTrack}>
|
<div className={styles.att8VizTrack}>
|
||||||
@@ -743,8 +800,8 @@ export function SecondarySchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={true}
|
isSecondary={true}
|
||||||
nationalAtt8Avg={heroAtt8Nat}
|
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SecondarySchoolRow.module.css';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
function detectAdmissionsTag(school: School): string | null {
|
function detectAdmissionsTag(school: School): string | null {
|
||||||
@@ -55,8 +55,14 @@ export function SecondarySchoolRow({
|
|||||||
const ofstedBadge = buildOfstedListBadge(school);
|
const ofstedBadge = buildOfstedListBadge(school);
|
||||||
const phase = getPhaseStyle(school.phase);
|
const phase = getPhaseStyle(school.phase);
|
||||||
const att8 = school.attainment_8_score;
|
const att8 = school.attainment_8_score;
|
||||||
|
// The school's own Attainment 8 is a same-school figure — shown whenever it
|
||||||
|
// exists (special schools included; their type tag on line 2 gives context).
|
||||||
|
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
|
||||||
|
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||||
const laDelta =
|
const laDelta =
|
||||||
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
||||||
|
? att8 - laAvgAttainment8
|
||||||
|
: null;
|
||||||
|
|
||||||
const admissionsTag = detectAdmissionsTag(school);
|
const admissionsTag = detectAdmissionsTag(school);
|
||||||
const sixthForm = hasSixthForm(school);
|
const sixthForm = hasSixthForm(school);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||||
|
warning. Shared by both detail views so the styling can't drift. */
|
||||||
|
.note {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
.note strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* SpecialSchoolNote — the context note shown on special-school / PRU / AP
|
||||||
|
* detail pages explaining why the mainstream England-average comparison is
|
||||||
|
* dropped. Renders nothing for mainstream schools.
|
||||||
|
*
|
||||||
|
* The copy is type-aware: only genuine special schools have pupils with special
|
||||||
|
* educational needs. Pupil referral units and alternative provision teach
|
||||||
|
* pupils educated outside a mainstream setting (e.g. after exclusion, or for
|
||||||
|
* medical reasons) who are not necessarily SEND — so their note says so rather
|
||||||
|
* than mischaracterising them. Suppressing the England comparison is reasonable
|
||||||
|
* for all three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isSpecialSchool } from '@/lib/utils';
|
||||||
|
import styles from './SpecialSchoolNote.module.css';
|
||||||
|
|
||||||
|
type SpecialKind = 'special' | 'pru' | 'ap';
|
||||||
|
|
||||||
|
function specialKind(schoolType: string | null | undefined): SpecialKind {
|
||||||
|
const t = (schoolType ?? '').toLowerCase();
|
||||||
|
if (/pupil referral/.test(t)) return 'pru';
|
||||||
|
if (/alternative provision/.test(t)) return 'ap';
|
||||||
|
return 'special';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) {
|
||||||
|
if (!isSpecialSchool(school)) return null;
|
||||||
|
const kind = specialKind(school.school_type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.note} role="note">
|
||||||
|
{kind === 'special' && (
|
||||||
|
<>
|
||||||
|
<strong>This is a special school.</strong> Its pupils have special educational needs and
|
||||||
|
work towards individual targets. They sit the same national assessments, but very few
|
||||||
|
reach the mainstream “expected standard” these measures report — so a
|
||||||
|
comparison with the England average isn’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'pru' && (
|
||||||
|
<>
|
||||||
|
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
|
||||||
|
mainstream school — for example after exclusion, or for medical or behavioural reasons.
|
||||||
|
The mainstream “expected standard” and the England-average comparison
|
||||||
|
aren’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'ap' && (
|
||||||
|
<>
|
||||||
|
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
|
||||||
|
outside a mainstream school. The mainstream “expected standard” and the
|
||||||
|
England-average comparison aren’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
Where available, the progress its pupils make is a fairer measure.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||||
|
import { isSpecialSchool } from '@/lib/utils';
|
||||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
import { DotStrip } from '@/components/DotStrip';
|
import { DotStrip } from '@/components/DotStrip';
|
||||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -90,15 +91,19 @@ function Strip({
|
|||||||
urns,
|
urns,
|
||||||
schoolNames,
|
schoolNames,
|
||||||
national,
|
national,
|
||||||
|
special,
|
||||||
}: {
|
}: {
|
||||||
spec: StripSpec;
|
spec: StripSpec;
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
urns: number[];
|
urns: number[];
|
||||||
schoolNames: string[];
|
schoolNames: string[];
|
||||||
national: Record<string, number> | undefined;
|
national: Record<string, number> | undefined;
|
||||||
|
/** Per-school special-school flag; special schools' mainstream attainment is
|
||||||
|
* not a fair comparison, so it's dropped from the strip (no dot). */
|
||||||
|
special: boolean[];
|
||||||
}) {
|
}) {
|
||||||
const values = latestValues(data, urns, spec.metric).map((v) =>
|
const values = latestValues(data, urns, spec.metric).map((v, i) =>
|
||||||
v != null ? Math.round(v) : null,
|
v != null && !special[i] ? Math.round(v) : null,
|
||||||
);
|
);
|
||||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||||
const anchor =
|
const anchor =
|
||||||
@@ -135,12 +140,19 @@ export function CompareAcademics({
|
|||||||
}) {
|
}) {
|
||||||
const urns = schools.map((school) => school.urn);
|
const urns = schools.map((school) => school.urn);
|
||||||
const schoolNames = schools.map((school) => school.school_name);
|
const schoolNames = schools.map((school) => school.school_name);
|
||||||
|
// Special schools / PRUs / AP: their pupils sit the same assessments but very
|
||||||
|
// few reach the mainstream standard, so their attainment isn't a fair
|
||||||
|
// like-for-like comparison — drop it (progress banding, which IS meaningful,
|
||||||
|
// is kept).
|
||||||
|
const specialFlags = schools.map((school) => isSpecialSchool(school));
|
||||||
|
const dropSpecial = (vals: Array<number | null>) =>
|
||||||
|
vals.map((v, i) => (specialFlags[i] ? null : v));
|
||||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSecondary) {
|
if (isSecondary) {
|
||||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
|
||||||
const banding = urns.map((urn) => {
|
const banding = urns.map((urn) => {
|
||||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
for (let i = rows.length - 1; i >= 0; i--) {
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
@@ -159,8 +171,8 @@ export function CompareAcademics({
|
|||||||
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||||
return y >= 202425;
|
return y >= 202425;
|
||||||
});
|
});
|
||||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
|
||||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
|
||||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||||
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||||
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||||
@@ -272,7 +284,7 @@ export function CompareAcademics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const national = nationalAverages?.primary;
|
const national = nationalAverages?.primary;
|
||||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
|
||||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||||
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||||
// groups where single pupils move the percentage — show roughly how many
|
// groups where single pupils move the percentage — show roughly how many
|
||||||
@@ -307,6 +319,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -320,6 +333,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<p className={styles.stripNote}>
|
<p className={styles.stripNote}>
|
||||||
|
|||||||
+4
-19
@@ -18,6 +18,7 @@ export interface School {
|
|||||||
religious_denomination: string | null;
|
religious_denomination: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
has_sixth_form?: boolean | null;
|
has_sixth_form?: boolean | null;
|
||||||
|
nursery_provision?: boolean | null;
|
||||||
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
|
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
|
||||||
|
|
||||||
// Address
|
// Address
|
||||||
@@ -60,11 +61,14 @@ export interface School {
|
|||||||
|
|
||||||
// GIAS enrichment fields
|
// GIAS enrichment fields
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
telephone?: string | null;
|
||||||
headteacher_name?: string | null;
|
headteacher_name?: string | null;
|
||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
trust_name?: string | null;
|
trust_name?: string | null;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
admissions_policy?: string | null;
|
admissions_policy?: string | null;
|
||||||
|
county?: string | null;
|
||||||
|
parliamentary_constituency?: string | null;
|
||||||
|
|
||||||
// Ofsted (for list view — summary only)
|
// Ofsted (for list view — summary only)
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
@@ -130,7 +134,6 @@ export interface SchoolCensus {
|
|||||||
male_pupils: number | null;
|
male_pupils: number | null;
|
||||||
fsm_pct: number | null;
|
fsm_pct: number | null;
|
||||||
eal_pct: number | null;
|
eal_pct: number | null;
|
||||||
class_size_avg?: number | null;
|
|
||||||
ethnicity_white_pct?: number | null;
|
ethnicity_white_pct?: number | null;
|
||||||
ethnicity_asian_pct?: number | null;
|
ethnicity_asian_pct?: number | null;
|
||||||
ethnicity_black_pct?: number | null;
|
ethnicity_black_pct?: number | null;
|
||||||
@@ -159,22 +162,6 @@ export interface SchoolAdmissions {
|
|||||||
cross_la_offers?: number | null;
|
cross_la_offers?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SenDetail {
|
|
||||||
year: number;
|
|
||||||
primary_need_speech_pct: number | null;
|
|
||||||
primary_need_autism_pct: number | null;
|
|
||||||
primary_need_mld_pct: number | null;
|
|
||||||
primary_need_spld_pct: number | null;
|
|
||||||
primary_need_semh_pct: number | null;
|
|
||||||
primary_need_physical_pct: number | null;
|
|
||||||
primary_need_other_pct: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Phonics {
|
|
||||||
year: number;
|
|
||||||
year1_phonics_pct: number | null;
|
|
||||||
year2_phonics_pct: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchoolDeprivation {
|
export interface SchoolDeprivation {
|
||||||
lsoa_code: string | null;
|
lsoa_code: string | null;
|
||||||
@@ -343,8 +330,6 @@ export interface SchoolDetailsResponse {
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
admissions_history: SchoolAdmissions[];
|
admissions_history: SchoolAdmissions[];
|
||||||
sen_detail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -795,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
|
|||||||
export function isProposedToClose(school: { status?: string | null }): boolean {
|
export function isProposedToClose(school: { status?: string | null }): boolean {
|
||||||
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special schools, pupil referral units and alternative provision teach pupils
|
||||||
|
* with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
|
||||||
|
* assessments but very few reach the mainstream "expected standard", so the
|
||||||
|
* headline attainment measures — and any comparison to the England average —
|
||||||
|
* are not a fair judgement of the school. Callers use this to drop the
|
||||||
|
* mainstream-benchmark framing (deltas, "below England", national markers)
|
||||||
|
* rather than portray these schools as failing.
|
||||||
|
*
|
||||||
|
* Detection is by establishment type: every DfE special-school type contains
|
||||||
|
* "special" (e.g. "Community special school", "Academy special converter/sponsor
|
||||||
|
* led", "Foundation special school", "Non-maintained special school", "Free
|
||||||
|
* schools special", "Other independent special school"); PRUs and alternative
|
||||||
|
* provision are matched by name. Special schools carry a mainstream `phase`
|
||||||
|
* (Primary/Secondary/All-through), so `phase` alone can't identify them.
|
||||||
|
*/
|
||||||
|
export function isSpecialSchool(school: { school_type?: string | null }): boolean {
|
||||||
|
const t = (school.school_type ?? '').toLowerCase();
|
||||||
|
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user