Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 4522cbf645 fix(ui): shorten proposed-to-close notice copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:51:32 +01:00
tudor 7370712888 Merge pull request 'feat: include and mark 'Open, but proposed to close' schools' (#22) from feat/proposed-to-close-schools into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 55s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 41s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s
Reviewed-on: #22
2026-07-08 21:23:23 +00:00
TudorandClaude Fable 5 45ab479062 feat(ui): mark proposed-to-close schools in listings and detail pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m46s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m1s
Amber tag in listing rows (option A) and a slim notice strip under the
detail-page header (option E): proposed for closure, formal process not
necessarily started, check with the local authority before applying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:05:55 +01:00
TudorandClaude Fable 5 6f602f4a9e feat(api): expose GIAS establishment status on school payloads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:05:55 +01:00
TudorandClaude Fable 5 de81e9cdbd feat(pipeline): include 'Open, but proposed to close' schools in dims
These schools are still operating and publish results; they drop out
automatically when GIAS flips them to Closed since marts fully rebuild
each run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:54:08 +01:00
tudor 45c68b60b4 Merge pull request 'feat: drive sixth-form separation from GIAS OfficialSixthForm flag' (#21) from feat/gias-sixth-form-flag into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m23s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 0s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 44s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #21
2026-07-07 13:48:12 +00:00
19 changed files with 193 additions and 7 deletions
+1
View File
@@ -604,6 +604,7 @@ 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"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": latest.get("phase"),
+1
View File
@@ -129,6 +129,7 @@ _MAIN_QUERY = text("""
s.gender,
s.age_range,
s.has_sixth_form,
s.status,
s.admissions_policy,
s.capacity,
s.total_pupils AS gias_total_pupils,
+1
View File
@@ -544,6 +544,7 @@ SCHOOL_COLUMNS = [
"religious_denomination",
"age_range",
"has_sixth_form",
"status",
"gender",
"admissions_policy",
"ofsted_grade",
+70
View File
@@ -0,0 +1,70 @@
"""Tests for GIAS establishment status exposure.
"Open, but proposed to close" schools are now kept by the dims; the API must
surface `status` on list items and school_info so the UI can render the
proposed-to-close marker (listing tag) and notice strip (detail page).
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
PROPOSED = "Open, but proposed to close"
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"age_range": "11-16",
"admissions_policy": None,
"has_sixth_form": False,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 800,
"rwm_expected_pct": np.nan,
"attainment_8_score": 48.0,
}
return pd.DataFrame(
[
{**base, "urn": 200001, "school_name": "Alpha Academy",
"status": "Open"},
{**base, "urn": 200002, "school_name": "Sarson High School",
"status": PROPOSED},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def test_list_payload_includes_status(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[200001]["status"] == "Open"
assert by_urn[200002]["status"] == PROPOSED
def test_detail_payload_includes_status(client):
resp = client.get("/api/schools/200002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["status"] == PROPOSED
@@ -44,3 +44,24 @@ describe('SecondarySchoolRow sixth-form tag', () => {
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow proposed-to-close tag', () => {
it('shows the tag when GIAS status is "Open, but proposed to close"', () => {
render(
<SecondarySchoolRow
school={{ ...base, status: 'Open, but proposed to close' }}
/>,
);
expect(screen.getByText(/Proposed to close/)).toBeInTheDocument();
});
it('hides the tag for a plain open school', () => {
render(<SecondarySchoolRow school={{ ...base, status: 'Open' }} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
it('hides the tag when status is missing', () => {
render(<SecondarySchoolRow school={base} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
});
+11
View File
@@ -212,3 +212,14 @@ describe('computeYBounds', () => {
expect(computeYBounds([], 'progress')).toEqual({});
});
});
describe('isProposedToClose', () => {
const { isProposedToClose } = require('@/lib/utils');
it('is true only for the exact GIAS proposed-to-close status', () => {
expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true);
expect(isProposedToClose({ status: 'Open' })).toBe(false);
expect(isProposedToClose({ status: null })).toBe(false);
expect(isProposedToClose({})).toBe(false);
});
});
@@ -1543,3 +1543,18 @@
.historyDisclosure[open] > .historyToggle::before {
transform: rotate(90deg);
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
+7 -1
View File
@@ -18,7 +18,7 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear,
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
@@ -313,6 +313,12 @@ export function SchoolDetailView({
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -254,3 +254,10 @@
justify-content: center;
}
}
/* GIAS "Open, but proposed to close" marker */
.attrClosing {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+4 -1
View File
@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange } from '@/lib/utils';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -78,6 +78,9 @@ export function SchoolRow({
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div>
{/* Line 3: Key stats */}
@@ -1099,3 +1099,18 @@
padding: 0.75rem;
}
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
@@ -23,7 +23,7 @@ import type {
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
@@ -237,6 +237,12 @@ export function SecondarySchoolDetailView({
</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -266,3 +266,9 @@
justify-content: center;
}
}
.closingTag {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+4 -1
View File
@@ -11,7 +11,7 @@
'use client';
import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange } from '@/lib/utils';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
@@ -97,6 +97,9 @@ export function SecondarySchoolRow({
{admissionsTag}
</span>
)}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}> Proposed to close</span>
)}
</div>
{/* Line 3: KS4 stats */}
+1
View File
@@ -18,6 +18,7 @@ export interface School {
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
address1: string | null;
+15
View File
@@ -718,3 +718,18 @@ export function buildOfstedListBadge(school: {
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
}
// ============================================================================
// Establishment status
// ============================================================================
export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
/**
* GIAS lists some operating schools as "Open, but proposed to close".
* They remain open (and may stay open if the proposal is withdrawn), but the
* UI marks them so families check with the local authority before applying.
*/
export function isProposedToClose(school: { status?: string | null }): boolean {
return school.status === PROPOSED_TO_CLOSE_STATUS;
}
@@ -30,7 +30,7 @@ models:
- name: status
tests:
- accepted_values:
values: ["Open"]
values: ["Open", "Open, but proposed to close"]
- name: dim_location
description: School location dimension with PostGIS geometry
@@ -31,4 +31,5 @@ select
else null
end as longitude
from {{ ref('stg_gias_establishments') }} s
where s.status = 'Open'
-- Must match dim_school's status filter exactly (the API inner-joins the two).
where s.status in ('Open', 'Open, but proposed to close')
@@ -91,4 +91,7 @@ from schools s
{% if ofsted_relation is not none %}
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
{% endif %}
where s.status = 'Open'
-- "Open, but proposed to close" schools are still operating (pupils enrolled,
-- results published) — include them; they drop out automatically once GIAS
-- flips them to "Closed" (marts are fully rebuilt each run).
where s.status in ('Open', 'Open, but proposed to close')