Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 993a6133cf fix(compare): give the trend chart its own definite canvas height so the gap note can't squash it
The chart note (and the 2-row mobile chip legend) lived inside the fixed
360px/420px chartBox and competed with the canvas for it, so a longer
caption — e.g. the new KS4 gap note — shrank the mobile canvas to ~178px
and tripped the e2e >220px guard. The canvas now owns a definite height
(280px mobile / 380px desktop) with chips above and the note flowing
below at natural size, so neither can eat the plot. Verified on staging:
patched canvas renders 280px vs the squashed 178px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 22:54:17 +01:00
TudorandClaude Fable 5 200a97d0b9 fix(compare): per-school P8 explanation; disadvantaged cohort from the same yearly row
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m24s
Review findings: (1) the 'no KS2 baseline (COVID)' explanation was derived
from the basket-wide max year, so a school with an unrelated data gap
borrowed it from a neighbour with 2024/25 data — now judged per school on
its own latest year. (2) The '~N disadvantaged pupils' cohort multiplied
eligible_pupils and disadvantaged_pct resolved independently across years
— now both come from the same yearly row that supplies the displayed
percentage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:53:10 +01:00
TudorandClaude Fable 5 f3fa12806b fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:41:12 +01:00
tudor 1004f08daf Merge pull request 'fix(pipeline): annual DAG never builds the official KS4 national mart' (#54) from fix/annual-dag-ks4-national-selector into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #54
2026-07-17 13:28:08 +00:00
TudorandClaude Fable 5 79246edc22 fix(pipeline): build stg_ees_ks4_national in the annual EES DAG
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 37s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
The DAG's dbt --select list predates the official-KS4-nationals stream,
so the extract loaded raw.ees_ks4_national but the staging model and
fact_ks4_national_averages were never rebuilt — staging kept serving the
old computed means after the DAG run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 14:15:11 +01:00
tudor 64b63b96c8 Merge pull request 'fix(compare): expert sign-off must-fixes M1-M3' (#53) from fix/expert-signoff-mustfixes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #53
2026-07-17 12:02:55 +00:00
TudorandClaude Fable 5 5944d88f0b fix(compare): expert sign-off must-fixes — phase-matched admissions, Ofsted sentinel codes, selective-school copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m53s
M1: admissions rounds are now selected by the active phase tab
(admissionsForPhase) — an all-through school's Year 7 round no longer
masquerades as Reception odds beside pure primaries; honest per-cell and
section fallbacks name the round (Reception / Year 7).
M2: Ofsted sentinel codes (9 = not applicable) never render as judgement
chips, and the sixth-form judgement — previously dropped — now renders
for schools that have one.
M3: 'What this means' is phase- and type-aware: selective schools get
entrance-test framing, secondary faith schools a faith-criteria note, and
the primaries' distance template never appears on the secondary tab
(admissions_policy now exposed in compare school_info).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 12:40:42 +01:00
tudor 163b501be6 Merge pull request 'fix(compare): stale-basket fetch blanking comparisons; rail caption' (#52) from fix/compare-final-review-mustfix into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 48s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #52
2026-07-17 06:28:27 +00:00
tudor e8f78a1598 Merge pull request 'fix(compare): align sticky school chips with their data columns on desktop' (#51) from fix/compare-final-review-mustfix into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 40s
Reviewed-on: #51
2026-07-16 20:10:45 +00:00
tudor 20a27f3958 Merge pull request 'fix(compare): five must-fix findings from the final expert review' (#50) from fix/compare-final-review-mustfix into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m33s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 51s
Reviewed-on: #50
2026-07-16 19:16:54 +00:00
20 changed files with 451 additions and 48 deletions
+3
View File
@@ -740,6 +740,9 @@ async def compare_schools(
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
"age_range": convert_to_native(latest.get("age_range")),
"gender": convert_to_native(latest.get("gender")),
# Needed by the admissions "What this means" copy: selective
# schools get entrance-test framing, never the distance template.
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
"capacity": convert_to_native(latest.get("capacity")),
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
+5
View File
@@ -237,6 +237,11 @@ test('comparing two secondary schools renders the secondary sections', async ({
// A KS4 measure proves the secondary academics variant rendered.
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
// The admissions template must be phase-aware: the primaries' distance
// copy ("non-faith primaries") must never appear on a secondary comparison
// (expert sign-off must-fix M3).
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
});
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
@@ -0,0 +1,140 @@
/**
* Getting a place — phase and school-type correctness (expert sign-off
* must-fixes M1/M3):
* - an all-through school's Year 7 round must never render on the primary
* tab as if it were Reception odds;
* - selective schools get entrance-test framing, and the secondary tab
* never shows the primaries' distance template.
*/
import { render, screen } from '@testing-library/react';
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
function school(urn: number, name: string, extra: Partial<School> = {}): School {
return { urn, school_name: name, ...extra } as School;
}
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
return {
year: 202627,
school_phase: 'Secondary',
places_offered: 173,
total_applications: 433,
first_preference_offer_pct: 83,
oversubscribed: true,
...partial,
} as SchoolAdmissions;
}
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
return {
school_info: info,
yearly_data: [],
ofsted: null,
census: null,
admissions: a,
admissions_history: a ? [a] : [],
deprivation: null,
};
}
describe('CompareAdmissions', () => {
it("does not show an all-through school's Year 7 round on the primary tab", () => {
// The real M1 scenario: an all-through school (Year 7 round only) beside
// a primary with a Reception round.
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const primary = school(138690, 'Barclay Primary School');
const data = {
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
'138690': entry(
primary,
admissions({
school_phase: 'Primary',
total_applications: 300,
places_offered: 120,
first_preference_offer_pct: 96,
}),
),
};
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
// Hessle's Year 7 figures must not appear…
expect(screen.queryByText('433')).toBeNull();
expect(
screen.getByText(/We don't hold Reception admissions data for this school/),
).toBeInTheDocument();
// …while Barclay's Reception round renders normally.
expect(screen.getByText('300')).toBeInTheDocument();
});
it('phase-labels the section empty state when no matching round exists at all', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
expect(
screen.getByText(/No Reception admissions data is available for these schools yet/),
).toBeInTheDocument();
expect(screen.queryByText('433')).toBeNull();
});
it('shows the Year 7 round on the secondary tab', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
expect(screen.getByText('433')).toBeInTheDocument();
expect(screen.getByText('173')).toBeInTheDocument();
});
it('gives selective schools entrance-test framing, never the distance template', () => {
const grammar = school(136276, 'Watford Grammar School for Boys', {
admissions_policy: 'Selective',
religious_denomination: 'Church of England',
});
const data = {
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
};
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
expect(
screen.getByText(/Entry is by entrance test — the school is selective/),
).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
const faithSchool = school(102052, "Bishop Stopford's School", {
admissions_policy: 'Non-selective',
religious_denomination: 'Church of England',
});
const data = {
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
};
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
const primary = school(100140, 'Plumcroft Primary School');
const data = {
'100140': entry(
primary,
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
),
};
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
});
});
@@ -96,6 +96,31 @@ describe('CompareOfsted', () => {
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
});
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
const sentinelSchool = school(6, 'Sentinel School');
const sentinelData: Record<string, ComparisonData> = {
'6': {
school_info: sentinelSchool,
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
grade_source: 'graded',
quality_of_education: 1,
early_years_provision: 9,
sixth_form_provision: 2,
}),
},
};
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
// Real grades render…
expect(screen.getByText('Quality of education')).toBeInTheDocument();
// …the applicable sixth-form judgement renders (was previously dropped)…
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
// …and the not-applicable sentinel never appears, neither as area nor code.
expect(screen.queryByText('Early years provision')).toBeNull();
expect(screen.queryByText('9')).toBeNull();
});
it('dates a report card with the report-card inspection date, never the legacy date', () => {
const cardSchool = school(4, 'Dated Card School');
const cardData: Record<string, ComparisonData> = {
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
});
it('lists England-only years so the component can caption dashed-only stretches', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
});
expect(chart.englandOnlyYears).toEqual([202122]);
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(none.englandOnlyYears).toEqual([]);
});
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
@@ -7,6 +7,7 @@
import {
OFSTED_LEGACY_GRADES,
admissionsForPhase,
ofstedDisplay,
progressBand,
rcAreaLabel,
@@ -176,6 +177,16 @@ describe('summariseAdmissions', () => {
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
});
it('60% → "About 1 in 3 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
});
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
});
it('100% → "All first choices offered"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
@@ -188,6 +199,44 @@ describe('summariseAdmissions', () => {
});
});
describe('admissionsForPhase', () => {
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
it('returns the latest round matching the active phase', () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
};
expect(admissionsForPhase(data, true)?.year).toBe(202627);
expect(admissionsForPhase(data, false)?.year).toBe(202526);
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
});
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary')],
};
expect(admissionsForPhase(data, false)).toBeNull();
expect(admissionsForPhase(data, true)?.year).toBe(202627);
});
it('uses untagged legacy rows only when no row carries a phase', () => {
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(mixed, false)).toBeNull();
});
it('handles missing data', () => {
expect(admissionsForPhase(null, false)).toBeNull();
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
});
});
describe('progressBand', () => {
it('CI entirely above zero → above', () => {
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
@@ -1,16 +1,23 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
parent .chartContainer, whose fixed height drives Chart.js sizing via
maintainAspectRatio: false. */
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
The canvas has its OWN definite height (Chart.js needs one for
maintainAspectRatio: false); the chips and the note flow at their natural
size around it rather than competing with it for a fixed outer height —
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
never squash the chart. */
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.canvasBox {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 380px;
}
@media (max-width: 640px) {
.canvasBox {
height: 280px;
}
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
+17 -3
View File
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
/** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>;
/** KS4 metrics get a different (honest) gap caption than KS2. */
isSecondary?: boolean;
}
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: true,
title: {
display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: {
size: 12,
weight: 'bold',
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
{built.showUnpublished202122Note && (
{isSecondary && built.englandOnlyYears.length > 0 ? (
// KS4's honest story differs from KS2's: 2019/202020/21 school-level
// GCSE results weren't published (COVID grading); later years WERE
// published by DfE but aren't in our dataset yet.
<p className={styles.chartNote}>
School-level GCSE figures for 2019/20 and 2020/21 weren&apos;t published (COVID
grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p>
) : (
!isSecondary &&
built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
</p>
)
)}
</div>
);
+21 -5
View File
@@ -365,12 +365,18 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison"
>
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
{/* Fills the 200px label rail on desktop (hidden on mobile).
All-through schools must not be miscounted as "primary
schools"/"secondary schools" — mixed baskets get "· primary
view" phrasing instead. */}
<div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school
{activeSchools.length === 1 ? '' : 's'}
{activeSchools.every((sch) =>
sch.phase?.toLowerCase().includes(comparePhase),
)
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
: `${activeSchools.length} schools · ${comparePhase} view`}
</span>
</div>
{activeSchools.map((school, index) => (
@@ -390,7 +396,13 @@ export function ComparisonView({
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a>
<span className={styles.chipMeta}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
{[
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
school.local_authority,
school.school_type,
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
<button
@@ -422,7 +434,11 @@ export function ComparisonView({
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareAdmissions
schools={activeSchools}
data={activeComparisonData}
isSecondary={!isPrimary}
/>
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
@@ -148,6 +148,17 @@ export function CompareAcademics({
}
return null;
});
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
// sat no KS2 tests (COVID), so there is no baseline to measure progress
// from. A bare "No data" reads as a gap on our side — say why. Judged
// PER SCHOOL on its own latest data year: a school whose data simply
// stops earlier (an unrelated gap) must not borrow the COVID explanation
// from a neighbour that does have 2024/25 data.
const p8NotPublished = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
@@ -189,6 +200,11 @@ export function CompareAcademics({
>
{banding[i]}
</Chip>
) : p8NotPublished[i] ? (
<span className={s.small}>
Not published this GCSE year group sat no KS2 tests (COVID), so DfE has no
baseline to measure progress from
</span>
) : (
<span className={s.small}>No data</span>
)}
@@ -218,6 +234,24 @@ export function CompareAcademics({
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
// pupils the figure rests on. Taken from the SAME yearly row that supplies
// the displayed percentage: resolving eligible_pupils and the
// disadvantaged share independently could mix years and misstate the
// cohort behind the figure.
const cohorts = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.rwm_expected_disadvantaged_pct != null) {
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
return cohort > 0 ? cohort : null;
}
}
return null;
});
return (
<Section
@@ -270,6 +304,9 @@ export function CompareAcademics({
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}%
</span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
@@ -7,7 +7,7 @@
'use client';
import { summariseAdmissions } from '@/lib/compareLogic';
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
import type { ComparisonData, School } from '@/lib/types';
import { CHART_COLORS } from '@/lib/utils';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -15,11 +15,17 @@ import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from '.
export function CompareAdmissions({
schools,
data,
isSecondary = false,
}: {
schools: School[];
data: Record<string, ComparisonData>;
isSecondary?: boolean;
}) {
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
// Admissions rounds are phase-specific: an all-through school's Year 7
// round must never stand in for Reception on the primary tab (and vice
// versa) — beside pure primaries it reads as Reception odds.
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
const anyData = rows.some(Boolean);
const entryYear = rows.find(Boolean)?.year;
const entryLabel = entryYear
@@ -28,7 +34,10 @@ export function CompareAdmissions({
if (!anyData) {
return (
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
<Section
title="Getting a place"
how={`No ${roundLabel} admissions data is available for these schools yet.`}
>
<></>
</Section>
);
@@ -63,7 +72,9 @@ export function CompareAdmissions({
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
</>
) : (
<span className={s.small}>No data</span>
<span className={s.small}>
We don&apos;t hold {roundLabel} admissions data for this school
</span>
)}
</Cell>
);
@@ -104,15 +115,28 @@ export function CompareAdmissions({
{schools.map((school, i) => {
const a = rows[i];
const summary = summariseAdmissions(a);
const info = data[String(school.urn)]?.school_info;
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
const faith =
!!info?.religious_denomination &&
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
let text: string | null = null;
if (summary.firstPrefPct != null) {
if (summary.firstPrefPct >= 100) {
if (selective) {
// Selective schools: the entrance test decides, whatever the
// offer percentage looks like — never the distance template.
text =
'Entry is by entrance test — the school is selective; distance and preference rank dont decide places.';
} else if (summary.firstPrefPct >= 100) {
text = `Every family who put ${school.school_name} first got a place.`;
} else if (summary.firstPrefPct >= 90) {
text = `Nearly every family who put ${school.school_name} first got a place.`;
} else if (a?.oversubscribed) {
text =
'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
text = isSecondary
? faith
? 'More first-choice applications than places — check the schools admission criteria (faith-based criteria may apply).'
: 'More first-choice applications than places — check the schools admission criteria (catchment or distance often decides, but criteria vary).'
: 'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
} else {
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
}
@@ -8,6 +8,7 @@
'use client';
import {
admissionsForPhase,
latestValues,
ofstedDisplay,
summariseAdmissions,
@@ -145,7 +146,11 @@ export function CompareAtAGlance({
<Measure label="Getting a place">
{schools.map((school, i) => {
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
// Phase-matched round only — an all-through school's Year 7 round
// must not masquerade as Reception odds on the primary tab.
const summary = summariseAdmissions(
admissionsForPhase(data[String(school.urn)], isSecondary),
);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.chip ? (
@@ -165,8 +170,14 @@ export function CompareAtAGlance({
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
// An all-through school's roll covers every age group, so judging
// it against the single-phase median ("Much larger than average")
// is meaningless — label the roll honestly instead.
const isAllThrough = /all.?through/i.test(school.phase ?? '');
let sizeNote: string | null = null;
if (pupils != null && medianPupils != null) {
if (isAllThrough) {
sizeNote = 'Whole-school roll (all-through, all ages)';
} else if (pupils != null && medianPupils != null) {
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
@@ -48,10 +48,25 @@ export function CompareCommunity({
);
};
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
return (
<Section
title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
how={
<>
The school&apos;s community, from the latest school census. State-school averages are
computed from our dataset and shown for context there&apos;s no &ldquo;right&rdquo;
number here.
{anyAllThrough && (
<>
{' '}
For all-through schools these figures cover the whole school, all ages not just
the {isSecondary ? 'secondary' : 'primary'} phase.
</>
)}
</>
}
>
<SectionGrid schools={schools}>
<Measure label="Pupils on roll">
@@ -108,14 +108,21 @@ function JudgementDetailCell({
);
}
const legacyAreas: Array<[string, number | null]> = [
const legacyAreas: Array<[string, number | null | undefined]> = [
['Quality of education', ofsted.quality_of_education],
['Behaviour & attitudes', ofsted.behaviour_attitudes],
['Personal development', ofsted.personal_development],
['Leadership & management', ofsted.leadership_management],
['Early years provision', ofsted.early_years_provision],
['Sixth form provision', ofsted.sixth_form_provision],
];
const published = legacyAreas.filter(([, grade]) => grade != null);
// Only real Ofsted grades (14) are judgements. The MI file uses sentinel
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
// must never render as a rating chip.
const published = legacyAreas.filter(
(entry): entry is [string, number] =>
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
);
if (published.length === 0) {
return (
@@ -60,18 +60,10 @@
margin: 0 0 1rem;
}
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
its container's height — which must be *definite*. A min-height alone does
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
to its ~150px default (a squashed sliver). Give it a real height. */
/* ComparisonChart owns its own canvas height now (a definite px value per
breakpoint), with the mobile chip legend above and the gap note below it
flowing at natural size. This box therefore only needs to not constrain
that height — no fixed height, or the note would again eat the plot. */
.chartBox {
height: 420px;
}
@media (max-width: 640px) {
/* Taller on mobile: the mobile-only school chips sit above the canvas and
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
.chartBox {
height: 360px;
}
min-height: 0;
}
@@ -119,6 +119,7 @@ export function TrendsExplorer({
metric={metric}
metricLabel={metricLabel}
nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/>
</div>
</div>
+11 -6
View File
@@ -53,6 +53,10 @@ export interface CompareChart {
/** True when England published a 2021/22 figure but no school has one —
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean;
/** Years where the England overlay has a value but no school does — the
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
* KS4 have different honest explanations, so the component owns the copy). */
englandOnlyYears: number[];
}
export function buildCompareChart(
@@ -92,11 +96,12 @@ export function buildCompareChart(
}
}
const idx202122 = years.indexOf(202122);
const showUnpublished202122Note =
idx202122 >= 0 &&
englandDataset?.data[idx202122] != null &&
schoolDatasets.every((ds) => ds.data[idx202122] == null);
const englandOnlyYears = years.filter(
(year, i) =>
englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
const showUnpublished202122Note = englandOnlyYears.includes(202122);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
}
+41
View File
@@ -143,6 +143,41 @@ export interface AdmissionsSummary {
interest: string | null;
}
/**
* Pick the admissions round for the ACTIVE phase tab. An all-through school
* can carry only a Year 7 (Secondary) round — rendering that beside pure
* primaries' Reception rounds made 433-forms-for-173-places read as
* Reception odds. Rows matching the target phase win (latest year first);
* rows tagged with the OTHER phase are never substituted. Untagged rows
* (legacy data, no school_phase) are used only when no row carries a phase.
*/
export function admissionsForPhase(
data:
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
| null
| undefined,
isSecondary: boolean,
): SchoolAdmissions | null {
if (!data) return null;
const rows: SchoolAdmissions[] = [
...(data.admissions_history ?? []),
...(data.admissions ? [data.admissions] : []),
];
if (rows.length === 0) return null;
const target = isSecondary ? 'secondary' : 'primary';
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
const matching = rows
.filter((r) => r.school_phase?.toLowerCase() === target)
.sort(byYearDesc);
if (matching.length > 0) return matching[0];
const tagged = rows.some((r) => r.school_phase != null);
if (!tagged) return [...rows].sort(byYearDesc)[0];
return null;
}
export function summariseAdmissions(
a: SchoolAdmissions | null | undefined,
): AdmissionsSummary {
@@ -157,6 +192,12 @@ export function summariseAdmissions(
if (pct != null) {
if (pct >= 100) {
chip = { tone: 'good', text: 'All first choices offered' };
} else if (pct < 50) {
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
// than half missed out understated the worst cases by half.
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
} else if (pct < 67) {
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
} else if (pct < 75) {
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
} else {
+2
View File
@@ -87,6 +87,8 @@ export interface OfstedInspection {
quality_of_education: number | null;
behaviour_attitudes: number | null;
personal_development: number | null;
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
sixth_form_provision?: number | null;
leadership_management: number | null;
early_years_provision: number | null;
previous_overall: number | null;
+1 -1
View File
@@ -180,7 +180,7 @@ with DAG(
dbt_build_ees = BashOperator(
task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
)
sync_typesense_ees = BashOperator(