Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 f1dd12e539 fix(detail): let the special-school note span the full container width
Drop the note's `max-width: 72ch`, which left it stopping well short of the
section's right edge. It now fills the results-section container like the
subtitle paragraph above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:45:59 +01:00
21 changed files with 200 additions and 720 deletions
-8
View File
@@ -606,21 +606,17 @@ async def get_school_details(request: Request, urn: int):
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"nursery_provision": latest.get("nursery_provision"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": latest.get("phase"),
# GIAS fields
"website": latest.get("website"),
"telephone": latest.get("telephone"),
"headteacher_name": latest.get("headteacher_name"),
"capacity": latest.get("capacity"),
"total_pupils": latest.get("gias_total_pupils"),
"trust_name": latest.get("trust_name"),
"gender": latest.get("gender"),
"county": latest.get("county"),
"parliamentary_constituency": latest.get("parliamentary_constituency"),
}.items()
}
@@ -811,10 +807,6 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_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",
"reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress",
+1 -47
View File
@@ -169,8 +169,6 @@ _MAIN_QUERY = text("""
s.total_pupils AS gias_total_pupils,
s.headteacher_name,
s.website,
s.telephone,
s.nursery_provision,
foi.ofsted_grade,
foi.ofsted_date,
foi.ofsted_framework,
@@ -180,8 +178,6 @@ _MAIN_QUERY = text("""
l.address_line1 AS address1,
l.address_line2 AS address2,
l.town,
l.county,
l.parliamentary_constituency,
l.postcode,
l.latitude,
l.longitude,
@@ -305,32 +301,6 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
_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 = (
"phase_code",
"school_type_code",
@@ -339,11 +309,7 @@ _GIAS_CODE_COLUMN_NAMES = (
"admissions_policy_code",
)
# 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')
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
def _missing_column_name(exc: Exception) -> Optional[str]:
@@ -386,18 +352,6 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
except Exception as exc2:
print(f"Warning: Could not load school data from marts: {exc2}")
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:
print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame()
-18
View File
@@ -73,24 +73,6 @@ 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():
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
@@ -25,11 +25,6 @@ class _Ks2Row:
year = LATEST
rwm_expected_pct = 62.1
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:
@@ -89,16 +84,6 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
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):
# No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series.
-57
View File
@@ -121,51 +121,6 @@ test('school detail page renders name and performance data', async ({ page }) =>
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 }) => {
// Detail views detected report cards via `framework`, which the API never
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
@@ -483,18 +438,6 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
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 }) => {
await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
-8
View File
@@ -85,14 +85,6 @@ export default function RootLayout({
data-performance="true"
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>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" />
+5 -1
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -191,6 +191,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
@@ -203,6 +205,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
@@ -442,58 +442,6 @@
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 {
+3 -48
View File
@@ -54,9 +54,6 @@ interface Step {
title: string;
body: string;
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 */
tool?: { label: string; href: string };
}
@@ -65,16 +62,6 @@ const SECONDARY_STEPS: Step[] = [
{
title: 'Check entry criteria',
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: '/' },
},
{
@@ -86,14 +73,14 @@ const SECONDARY_STEPS: Step[] = [
{
date: '31 October',
title: 'Application deadline',
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.',
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.',
highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
},
{
date: '1 March',
title: 'National Offer Day',
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.',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' },
},
@@ -114,12 +101,6 @@ const PRIMARY_STEPS: Step[] = [
{
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.',
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: '/' },
},
{
@@ -163,7 +144,7 @@ interface Tip {
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
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.',
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.',
},
{
heading: 'Late applications go to the back',
@@ -326,19 +307,6 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<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 && (
<a
href={step.tool.href}
@@ -387,19 +355,6 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<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 && (
<a
href={step.tool.href}
+5 -5
View File
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
<div className={styles.editorialGrid}>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
and demographics each in its own table, each with its own jargon.
School performance data in England is rich but fragmented. The Department for Education publishes
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
demographics each in its own table, each with its own jargon.
</p>
<p>
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
parents, governors, journalists, and anyone who wants to understand a school without reading a
full inspection report.
40-page inspection report.
</p>
</div>
<div className={styles.factbox}>
+15 -67
View File
@@ -720,13 +720,10 @@
.miniRowHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.2rem;
font-size: 0.44rem;
font-size: 0.48rem;
color: var(--text-muted, #6d685f);
text-transform: uppercase;
letter-spacing: 0.01em;
white-space: nowrap;
letter-spacing: 0.04em;
}
.miniRowHead strong {
@@ -872,77 +869,28 @@
color: var(--text-primary, #1a1612);
}
.ofstedFramework {
.ofstedBadge {
align-self: flex-start;
padding: 0.15rem 0.5rem;
padding: 0.2rem 0.55rem;
border-radius: 4px;
background: rgba(90, 58, 110, 0.12);
color: #5a3a6e;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.rcList {
display: flex;
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;
letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
}
.rcChipGood {
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.rcChipNeutral {
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
color: var(--accent-gold-text, #7a6800);
.ofstedVerdict {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary, #1a1612);
}
/* 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;
.ofstedVerdict em {
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.4);
font-style: normal;
}
.ofstedMeta {
+1 -1
View File
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.heroSection}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated for the 2026/27 admissions round
Updated with 2026/2027 admissions results
</span>
<h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em>
+15 -37
View File
@@ -5,26 +5,15 @@
import styles from './HomeView.module.css';
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 = [
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
];
const compareRows = [
{ 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 },
];
// 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 },
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
];
return (
@@ -40,7 +29,7 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseBlock}>
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
<div className={styles.miniCascade}>
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
{miniCascade.map(({ subj, exp, exc, nat }) => (
<div key={subj} className={styles.miniCascadeCol}>
<div className={styles.miniSubj}>{subj}</div>
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
@@ -48,7 +37,7 @@ export function HowItWorksSection() {
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
</div>
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
<div className={styles.miniTrack}>
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
</div>
@@ -60,15 +49,15 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
<div className={styles.att8Row}>
<div className={styles.att8BarWrap}>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
<div className={styles.att8Track}>
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
<div className={styles.att8Fill} style={{ width: '62%' }} />
<div className={styles.att8NatLine} style={{ left: '50%' }} />
</div>
</div>
<div className={styles.att8Score}>
<div className={styles.att8Value}>54.8</div>
<div className={styles.att8Delta}>+8.9 vs national</div>
<div className={styles.att8Value}>62.4</div>
<div className={styles.att8Delta}>+12.2 vs national</div>
</div>
</div>
</div>
@@ -88,26 +77,15 @@ export function HowItWorksSection() {
<span className={styles.ofstedBullet} />
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
</div>
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
<div className={styles.rcList}>
{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>
<span className={styles.ofstedBadge}>OUTSTANDING</span>
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Judgement</div>
<div className={styles.hiwTitle}>Ofsted at a glance</div>
<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>
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline without opening a 40-page report.</p>
</div>
</div>
+3 -10
View File
@@ -118,7 +118,7 @@ export function RankingsView({
<header className={styles.header}>
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Schools ranked by {metricLabel.toLowerCase()}
Top-performing schools by {metricLabel.toLowerCase()}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p>
</header>
@@ -143,10 +143,7 @@ export function RankingsView({
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
{isProgressScore && (
<p className={styles.progressHint}>
Progress scores: 0 = national average. Positive = above average.
{isPrimary && ' KS2 progress isnt published for 2023/24 or 2024/25 (theres no key stage 1 baseline) — pick an earlier year to rank by it.'}
</p>
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
)}
{/* Filters */}
@@ -221,11 +218,7 @@ export function RankingsView({
{rankings.length === 0 ? (
<EmptyState
title="No rankings found"
message={
isPrimary && isProgressScore
? 'KS2 progress scores arent published for the most recent years (2023/24 and 2024/25) because theres no key stage 1 baseline. Select an earlier year to see progress rankings.'
: 'Try selecting a different metric, area, or year.'
}
message="Try selecting a different metric, area, or year."
action={{
label: 'Clear filters',
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
+15 -31
View File
@@ -44,46 +44,30 @@
background: var(--bg-secondary, #f3ede4);
}
/* ── Per-bar national average marker ──
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 {
/* ── National average marker ── */
.natLine {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-50%);
background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
top: 0;
height: calc(100% - 20px);
width: 1.5px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
z-index: 2;
pointer-events: none;
}
.natTick::before {
content: '';
.natPill {
position: absolute;
top: -3px;
left: 50%;
top: -10px;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256);
}
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
color: #fff;
font-size: 0.55rem;
font-weight: 700;
color: var(--accent-coral-dark, #b04a2e);
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
z-index: 3;
letter-spacing: 0.02em;
}
/* ── Bar rows ── */
+36 -54
View File
@@ -8,9 +8,6 @@ interface SubjectData {
expectedPct: number | null;
exceedingPct: 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 {
@@ -20,45 +17,11 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100];
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 }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
// Animate bars on mount
useEffect(() => {
@@ -88,25 +51,44 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))}
</div>
{/* Bars — each carries its own national marker */}
{/* National average marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}>
{expectedPct != null && (
<BarRow
label="Expected"
value={expectedPct}
national={nationalExpectedPct}
barRef={expectedRef}
barClass={styles.barExpected}
/>
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Expected</span>
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/>
</div>
</div>
)}
{exceedingPct != null && (
<BarRow
label="Exceeding"
value={exceedingPct}
national={nationalExceedingPct}
barRef={exceedingRef}
barClass={styles.barExceeding}
/>
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Exceeding</span>
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/>
</div>
</div>
)}
</div>
@@ -153,7 +135,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
National average
</div>
</div>
@@ -197,22 +197,6 @@
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 */
.genderSplitValue {
display: flex;
@@ -871,10 +855,7 @@
/* Charts Section */
.chartContainer {
width: 100%;
/* 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;
height: 280px;
position: relative;
}
@@ -1059,41 +1040,6 @@
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 {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
@@ -1221,24 +1167,14 @@
gap: 0.375rem;
}
/* Secondary header info (headteacher, website, pupil count, trust,
contact, area) isn't needed above the fold on phones/tablets, so it's
collapsed by default and revealed on demand via the "Show all details"
link — reclaiming the vertical space so the metrics surface sooner. */
.detailsToggle {
display: inline-flex;
}
/* Secondary header info (headteacher, website, pupil count, trust)
isn't needed above the fold on phones — pupil count lives in the
Pupils & Inclusion section, website is one scroll away. Reclaim
the ~3 vertical lines so the actual metrics surface sooner. */
.headerDetails {
display: none;
}
.headerDetailsOpen {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
+68 -72
View File
@@ -14,11 +14,11 @@ import { MetricTooltip } from './MetricTooltip';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
@@ -68,13 +68,15 @@ interface SchoolDetailViewProps {
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SchoolDetailView({
schoolInfo, yearlyData, absenceData,
ofsted, census, admissions, admissionsHistory, deprivation, finance,
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
@@ -99,9 +101,6 @@ export function SchoolDetailView({
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
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)
// fall back to search so the button never dead-ends or leaves the site.
@@ -162,12 +161,14 @@ export function SchoolDetailView({
// Phase detection. All-through schools cover BOTH key stages, so they are
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
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)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -219,9 +220,11 @@ export function SchoolDetailView({
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|| (latestResults?.eal_pct != null)
|| (latestResults?.sen_support_pct != null)
|| senDetail != null
|| hasGenderSplit;
const hasSchoolLife = absenceData != null;
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
@@ -262,6 +265,7 @@ export function SchoolDetailView({
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
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 (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -354,15 +358,6 @@ export function SchoolDetailView({
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;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>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
@@ -387,20 +382,7 @@ export function SchoolDetailView({
)}
</p>
)}
<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}` : ''}`}
>
<div className={styles.headerDetails}>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
@@ -434,32 +416,6 @@ export function SchoolDetailView({
Part of <strong>{schoolInfo.trust_name}</strong>
</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 className={styles.actions} ref={heroActionsRef}>
@@ -621,7 +577,7 @@ export function SchoolDetailView({
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -667,7 +623,7 @@ export function SchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -796,22 +752,18 @@ export function SchoolDetailView({
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_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,
},
]}
/>
@@ -973,15 +925,6 @@ export function SchoolDetailView({
</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}>
{/* This-year Q&A */}
@@ -1128,6 +1071,29 @@ export function SchoolDetailView({
);
})()}
</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>
)}
@@ -1252,11 +1218,41 @@ export function SchoolDetailView({
)}
</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 */}
{hasSchoolLife && (
<section id="school-life" className={styles.card}>
<h2 className={styles.sectionTitle}>School Life</h2>
<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 && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
@@ -207,22 +207,6 @@
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 {
display: flex;
gap: 0.5rem;
@@ -506,10 +490,7 @@
/* ── Charts & Map ────────────────────────────────────── */
.chartContainer {
width: 100%;
/* 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;
height: 280px;
position: relative;
}
@@ -642,41 +623,6 @@
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 {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
@@ -1082,18 +1028,7 @@
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 {
display: none;
}
.headerDetailsOpen {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
@@ -20,7 +20,7 @@ const PerformanceChart = dynamic(
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
@@ -68,13 +68,15 @@ interface SecondarySchoolDetailViewProps {
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, census, admissions, deprivation, finance, absenceData,
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view.
@@ -83,8 +85,6 @@ export function SecondarySchoolDetailView({
const isInComparison = isSelected(schoolInfo.urn);
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;
@@ -248,9 +248,6 @@ export function SecondarySchoolDetailView({
{schoolInfo.age_range && (
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.badge}>Nursery</span>
)}
{hasSixthForm && (
<span className={styles.badge}>Sixth form</span>
)}
@@ -283,20 +280,7 @@ export function SecondarySchoolDetailView({
)}
</p>
)}
<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}` : ''}`}
>
<div className={styles.headerDetails}>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
@@ -326,32 +310,6 @@ export function SecondarySchoolDetailView({
Part of <strong>{schoolInfo.trust_name}</strong>
</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 className={styles.actions}>
@@ -416,7 +374,7 @@ export function SecondarySchoolDetailView({
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -461,7 +419,7 @@ export function SecondarySchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -478,7 +436,7 @@ export function SecondarySchoolDetailView({
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
+19 -4
View File
@@ -18,7 +18,6 @@ export interface School {
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
nursery_provision?: boolean | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
@@ -61,14 +60,11 @@ export interface School {
// GIAS enrichment fields
website?: string | null;
telephone?: string | null;
headteacher_name?: string | null;
capacity?: number | null;
trust_name?: string | null;
gender?: string | null;
admissions_policy?: string | null;
county?: string | null;
parliamentary_constituency?: string | null;
// Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null;
@@ -134,6 +130,7 @@ export interface SchoolCensus {
male_pupils: number | null;
fsm_pct: number | null;
eal_pct: number | null;
class_size_avg?: number | null;
ethnicity_white_pct?: number | null;
ethnicity_asian_pct?: number | null;
ethnicity_black_pct?: number | null;
@@ -162,6 +159,22 @@ export interface SchoolAdmissions {
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 {
lsoa_code: string | null;
@@ -330,6 +343,8 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
sen_detail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}