Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1df77961 | ||
|
|
5e370d09f1 | ||
|
|
f6bb037c47 | ||
|
|
9c93c3d9c2 | ||
|
|
17bd4d5a5e | ||
|
|
31ae13451a |
@@ -148,6 +148,37 @@ test('a report-card school shows its report card, dated to the report-card inspe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
|
||||||
|
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
|
||||||
|
// The detail view used to flip them to isSecondary and render GCSE-only,
|
||||||
|
// hiding the primary phase. It now renders both phases and labels the school
|
||||||
|
// "All-through".
|
||||||
|
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
|
||||||
|
const res = await page.request.get(`/api/schools/${AT_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const detail = await res.json();
|
||||||
|
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
|
||||||
|
detail.yearly_data ?? [];
|
||||||
|
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
|
||||||
|
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
|
||||||
|
test.skip(
|
||||||
|
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
|
||||||
|
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/school/${AT_URN}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Labelled as all-through in the hero meta.
|
||||||
|
await expect(page.getByText(/All-through/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The combined results section carries both phases.
|
||||||
|
const results = page.locator('#results');
|
||||||
|
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
|
||||||
|
await expect(results.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2
|
||||||
|
await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4
|
||||||
|
});
|
||||||
|
|
||||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2';
|
|||||||
import { ChartOptions } from 'chart.js';
|
import { ChartOptions } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import styles from './AdmissionsTrendChart.module.css';
|
import styles from './AdmissionsTrendChart.module.css';
|
||||||
|
|
||||||
@@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
||||||
if (pts.length < 2) return null;
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
const labels = pts.map((p) => formatAcademicYear(p.year));
|
// Gap-honest axis: every academic year between the first and last data point
|
||||||
const values = pts.map((p) => p.first_preference_offer_pct as number);
|
// appears, so a missing admissions year renders as a real gap (spanGaps:false)
|
||||||
const lastIdx = pts.length - 1;
|
// rather than compressing time between distant years.
|
||||||
|
const axisYears = fillAcademicYears(pts.map((p) => p.year));
|
||||||
|
const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number]));
|
||||||
|
const labels = axisYears.map(formatAcademicYear);
|
||||||
|
const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null);
|
||||||
|
const present = values
|
||||||
|
.map((v, i) => (v != null ? i : -1))
|
||||||
|
.filter((i) => i >= 0);
|
||||||
|
const lastIdx = present[present.length - 1];
|
||||||
|
|
||||||
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
||||||
const lo = Math.min(...values);
|
const numeric = values.filter((v): v is number => v != null);
|
||||||
const hi = Math.max(...values);
|
const lo = Math.min(...numeric);
|
||||||
|
const hi = Math.max(...numeric);
|
||||||
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
||||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||||
@@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
label: 'First-choice offer rate',
|
label: 'First-choice offer rate',
|
||||||
data: values,
|
data: values,
|
||||||
clip: false as const,
|
clip: false as const,
|
||||||
|
spanGaps: false,
|
||||||
borderColor: '#e07256',
|
borderColor: '#e07256',
|
||||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
|
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||||
pointBackgroundColor: '#e07256',
|
pointBackgroundColor: '#e07256',
|
||||||
pointBorderColor: '#fff',
|
pointBorderColor: '#fff',
|
||||||
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
|
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 6,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
|
|||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
@@ -35,8 +36,6 @@ interface PerformanceChartProps {
|
|||||||
nationalByYear?: NationalByYear[];
|
nationalByYear?: NationalByYear[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COVID_YEARS = new Set([201920, 202021]);
|
|
||||||
|
|
||||||
// Mobile chip definitions: which datasets render when each chip is active.
|
// Mobile chip definitions: which datasets render when each chip is active.
|
||||||
// `series` keys reference the dataset labels so we can filter cleanly.
|
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||||
@@ -67,21 +66,33 @@ export function PerformanceChart({
|
|||||||
nationalByYear,
|
nationalByYear,
|
||||||
}: PerformanceChartProps) {
|
}: PerformanceChartProps) {
|
||||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
|
||||||
|
// Gap-honest year axis: every academic year between the first and last data
|
||||||
|
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
|
||||||
|
// KS2 — 2021/22) render as real gaps rather than compressed time. School
|
||||||
|
// lines never bridge these gaps (spanGaps:false below).
|
||||||
|
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
|
||||||
|
const byYear = new Map(sortedData.map(d => [d.year, d]));
|
||||||
|
const col = (key: keyof SchoolResult): (number | null)[] =>
|
||||||
|
axisYears.map(y => {
|
||||||
|
const v = byYear.get(y)?.[key];
|
||||||
|
return typeof v === 'number' ? v : null;
|
||||||
|
});
|
||||||
|
const years = axisYears.map(formatAcademicYear);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// ── Build per-year national averages ─────────────────────────────────
|
// ── Build per-year national averages (aligned to the filled axis) ────
|
||||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
const natRefRwm: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.primary?.rwm_expected_pct ?? null;
|
return match?.primary?.rwm_expected_pct ?? null;
|
||||||
}
|
}
|
||||||
return nationalRwmAvg ?? null;
|
return nationalRwmAvg ?? null;
|
||||||
});
|
});
|
||||||
const natRefAtt8: (number | null)[] = sortedData.map(d => {
|
const natRefAtt8: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.secondary?.attainment_8_score ?? null;
|
return match?.secondary?.attainment_8_score ?? null;
|
||||||
}
|
}
|
||||||
return nationalAtt8Avg ?? null;
|
return nationalAtt8Avg ?? null;
|
||||||
@@ -107,10 +118,11 @@ export function PerformanceChart({
|
|||||||
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasCovidGap = isSecondary
|
// A gap year is any filled axis year the school has no results row for —
|
||||||
? false
|
// exactly the cancelled/unpublished years the fill introduced. Drives the
|
||||||
: COVID_YEARS.size > 0 &&
|
// honest note below (KS2 and KS4 have different gap stories).
|
||||||
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||||
|
const hasGap = gapYears.length > 0;
|
||||||
|
|
||||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||||
const refLineStyle = {
|
const refLineStyle = {
|
||||||
@@ -120,13 +132,14 @@ export function PerformanceChart({
|
|||||||
borderDash: [6, 4] as number[],
|
borderDash: [6, 4] as number[],
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0,
|
tension: 0,
|
||||||
|
spanGaps: false as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||||
{
|
{
|
||||||
label: 'Attainment 8',
|
label: 'Attainment 8',
|
||||||
data: sortedData.map(d => d.attainment_8_score),
|
data: col('attainment_8_score'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -137,7 +150,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'English & Maths Grade 4+',
|
label: 'English & Maths Grade 4+',
|
||||||
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
data: col('english_maths_standard_pass_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -147,7 +160,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Progress 8',
|
label: 'Progress 8',
|
||||||
data: sortedData.map(d => d.progress_8_score),
|
data: col('progress_8_score'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -165,7 +178,7 @@ export function PerformanceChart({
|
|||||||
] : [
|
] : [
|
||||||
{
|
{
|
||||||
label: 'Reading, Writing & Maths expected %',
|
label: 'Reading, Writing & Maths expected %',
|
||||||
data: sortedData.map(d => d.rwm_expected_pct),
|
data: col('rwm_expected_pct'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -176,7 +189,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Exceeding expected level',
|
label: 'Exceeding expected level',
|
||||||
data: sortedData.map(d => d.rwm_high_pct),
|
data: col('rwm_high_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -192,7 +205,7 @@ export function PerformanceChart({
|
|||||||
} as ChartDataset<'line'>] : []),
|
} as ChartDataset<'line'>] : []),
|
||||||
{
|
{
|
||||||
label: 'Reading progress',
|
label: 'Reading progress',
|
||||||
data: sortedData.map(d => d.reading_progress),
|
data: col('reading_progress'),
|
||||||
borderColor: 'rgb(59,130,246)',
|
borderColor: 'rgb(59,130,246)',
|
||||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -203,7 +216,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Writing progress',
|
label: 'Writing progress',
|
||||||
data: sortedData.map(d => d.writing_progress),
|
data: col('writing_progress'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -214,7 +227,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maths progress',
|
label: 'Maths progress',
|
||||||
data: sortedData.map(d => d.maths_progress),
|
data: col('maths_progress'),
|
||||||
borderColor: 'rgb(236,72,153)',
|
borderColor: 'rgb(236,72,153)',
|
||||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -275,6 +288,8 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
// Never bridge missing years — cancelled/unpublished years are real gaps.
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -326,6 +341,7 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
@@ -353,7 +369,10 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: { font: { size: 10 }, autoSkip: false },
|
// With the gap-honest axis (more year labels) autoSkip keeps the
|
||||||
|
// phone axis readable; the broken line still shows where a year is
|
||||||
|
// missing even when its tick label is skipped.
|
||||||
|
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -416,9 +435,11 @@ export function PerformanceChart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCovidGap && (
|
{hasGap && (
|
||||||
<p className={styles.covidNote}>
|
<p className={styles.covidNote}>
|
||||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
{isSecondary
|
||||||
|
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
|
||||||
|
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1040,6 +1040,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);
|
||||||
|
|||||||
@@ -158,10 +158,16 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
// Phase detection
|
// 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 (phonics,
|
||||||
|
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||||
|
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);
|
||||||
@@ -233,11 +239,11 @@ export function SchoolDetailView({
|
|||||||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||||||
const navItems: { id: string; label: string }[] = [];
|
const navItems: { id: string; label: string }[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
|
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||||||
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 && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
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' });
|
||||||
@@ -324,6 +330,9 @@ export function SchoolDetailView({
|
|||||||
{schoolInfo.school_type && (
|
{schoolInfo.school_type && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||||
)}
|
)}
|
||||||
|
{isAllThrough && (
|
||||||
|
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||||
|
)}
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
@@ -546,7 +555,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>
|
||||||
@@ -592,7 +601,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>
|
||||||
@@ -612,17 +621,22 @@ export function SchoolDetailView({
|
|||||||
{hasAnyResults && latestResults && (
|
{hasAnyResults && latestResults && (
|
||||||
<section id="results" className={styles.card}>
|
<section id="results" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{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}>
|
||||||
{isSecondary
|
{isAllThrough
|
||||||
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||||
: 'End-of-primary-school tests taken by Year 6 pupils. 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>
|
||||||
|
|
||||||
{/* ── Primary / KS2 content ── */}
|
{/* ── Primary / KS2 content ── */}
|
||||||
{hasKS2Results && (
|
{hasKS2Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.heroStatGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults.rwm_expected_pct !== null && (
|
{latestResults.rwm_expected_pct !== null && (
|
||||||
<div className={styles.heroStatCard}>
|
<div className={styles.heroStatCard}>
|
||||||
@@ -761,6 +775,9 @@ export function SchoolDetailView({
|
|||||||
{/* ── Secondary / KS4 content ── */}
|
{/* ── Secondary / KS4 content ── */}
|
||||||
{hasKS4Results && (
|
{hasKS4Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{latestResults.attainment_8_score !== null && (
|
{latestResults.attainment_8_score !== null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
@@ -1046,16 +1063,52 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.length > 0 && (
|
{yearlyData.length > 0 && (
|
||||||
<section id="history" className={styles.card}>
|
<section id="history" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||||
<div className={styles.chartContainer}>
|
{isAllThrough ? (
|
||||||
<PerformanceChart
|
// All-through: KS2 and KS4 trends are on different scales and have
|
||||||
data={yearlyData}
|
// different gap stories, so render them as two stacked charts
|
||||||
schoolName={schoolInfo.school_name}
|
// rather than crowding 8+ series onto one axis.
|
||||||
isSecondary={isSecondary}
|
<>
|
||||||
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
{hasKS2Results && (
|
||||||
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
<>
|
||||||
nationalByYear={nationalAvg?.by_year}
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs</h3>
|
||||||
/>
|
<div className={styles.chartContainer}>
|
||||||
</div>
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={false}
|
||||||
|
nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
|
||||||
|
nationalByYear={nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasKS4Results && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={true}
|
||||||
|
nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
|
||||||
|
nationalByYear={nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={isSecondary}
|
||||||
|
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||||
|
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||||
|
nationalByYear={nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<details className={styles.historyDisclosure}>
|
<details className={styles.historyDisclosure}>
|
||||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
@@ -1064,7 +1117,15 @@ export function SchoolDetailView({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Year</th>
|
<th>Year</th>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<th>RWM (expected %)</th>
|
||||||
|
<th>Exceeding (%)</th>
|
||||||
|
<th>Attainment 8</th>
|
||||||
|
<th>Progress 8</th>
|
||||||
|
<th>English & Maths Grade 4+</th>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<th>Attainment 8</th>
|
<th>Attainment 8</th>
|
||||||
<th>Progress 8</th>
|
<th>Progress 8</th>
|
||||||
@@ -1086,7 +1147,15 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.map((result) => (
|
{yearlyData.map((result) => (
|
||||||
<tr key={result.year}>
|
<tr key={result.year}>
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||||
|
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||||
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
|
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
@@ -1111,8 +1180,8 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Year 1 Phonics — primary only */}
|
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
||||||
{hasPhonics && isPrimary && phonics && (
|
{hasPhonics && showPrimaryContent && phonics && (
|
||||||
<section id="phonics" className={styles.card}>
|
<section id="phonics" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
|
|||||||
@@ -623,6 +623,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);
|
||||||
@@ -1049,8 +1084,12 @@
|
|||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile let the chart container flow naturally — PerformanceChart's
|
||||||
|
own .chartWrapper carries the definite canvas height (220px) plus the
|
||||||
|
chip strip above it. A fixed 220px here double-constrained the two and
|
||||||
|
clipped the chips onto the plot area. */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
height: 220px;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataTable {
|
.dataTable {
|
||||||
|
|||||||
@@ -363,7 +363,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 +408,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 +425,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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user