Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b5f4fdd62 |
@@ -51,14 +51,11 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt pytest "httpx<0.28"
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Import smoke test
|
- name: Import smoke test
|
||||||
run: python -c "from backend.app import app; print('backend imports OK')"
|
run: python -c "from backend.app import app; print('backend imports OK')"
|
||||||
|
|
||||||
- name: Backend unit tests
|
|
||||||
run: python -m pytest backend/tests -q
|
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
name: Build Backend (no push)
|
name: Build Backend (no push)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
venv
|
venv
|
||||||
__pycache__/
|
backend/__pycache__
|
||||||
|
|||||||
+6
-14
@@ -33,7 +33,7 @@ from .data_loader import (
|
|||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json, convert_to_native
|
from .utils import clean_for_json
|
||||||
|
|
||||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||||
@@ -572,7 +572,7 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
# Get latest info for the school
|
# Get latest info for the school
|
||||||
latest = school_data.iloc[-1]
|
latest = school_data.iloc[-1]
|
||||||
|
|
||||||
# Fetch supplementary data (Ofsted, admissions, etc.)
|
# Fetch supplementary data (Ofsted, Parent View, admissions, etc.)
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
supplementary = {}
|
supplementary = {}
|
||||||
try:
|
try:
|
||||||
@@ -582,13 +582,8 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Schools with no performance rows (post-16 institutions, PRUs, new
|
return {
|
||||||
# schools) carry NaN in every LEFT-JOINed numeric column; NaN reaching
|
"school_info": {
|
||||||
# JSONResponse raises ValueError, so school_info needs the same
|
|
||||||
# conversion yearly_data gets from clean_for_json.
|
|
||||||
school_info = {
|
|
||||||
k: convert_to_native(v)
|
|
||||||
for k, v in {
|
|
||||||
"urn": urn,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
@@ -606,14 +601,11 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
}.items()
|
},
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"school_info": school_info,
|
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
|
"parent_view": supplementary.get("parent_view"),
|
||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
|
|||||||
+25
-1
@@ -14,7 +14,7 @@ from .config import settings
|
|||||||
from .database import SessionLocal, engine
|
from .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactAdmissions,
|
FactOfstedInspection, FactParentView, FactAdmissions,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .schemas import SCHOOL_TYPE_MAP
|
from .schemas import SCHOOL_TYPE_MAP
|
||||||
@@ -446,6 +446,30 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parent View
|
||||||
|
pv = safe_query(FactParentView, "urn")
|
||||||
|
result["parent_view"] = (
|
||||||
|
{
|
||||||
|
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
|
||||||
|
"total_responses": pv.total_responses,
|
||||||
|
"q_happy_pct": pv.q_happy_pct,
|
||||||
|
"q_safe_pct": pv.q_safe_pct,
|
||||||
|
"q_behaviour_pct": pv.q_behaviour_pct,
|
||||||
|
"q_bullying_pct": pv.q_bullying_pct,
|
||||||
|
"q_communication_pct": pv.q_communication_pct,
|
||||||
|
"q_progress_pct": pv.q_progress_pct,
|
||||||
|
"q_teaching_pct": pv.q_teaching_pct,
|
||||||
|
"q_information_pct": pv.q_information_pct,
|
||||||
|
"q_curriculum_pct": pv.q_curriculum_pct,
|
||||||
|
"q_future_pct": pv.q_future_pct,
|
||||||
|
"q_leadership_pct": pv.q_leadership_pct,
|
||||||
|
"q_wellbeing_pct": pv.q_wellbeing_pct,
|
||||||
|
"q_recommend_pct": pv.q_recommend_pct,
|
||||||
|
}
|
||||||
|
if pv
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# Census (latest year of fact_pupil_characteristics)
|
# Census (latest year of fact_pupil_characteristics)
|
||||||
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||||
result["census"] = (
|
result["census"] = (
|
||||||
|
|||||||
@@ -433,25 +433,6 @@ def _apply_schema_alterations():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _apply_schema_drops():
|
|
||||||
"""
|
|
||||||
Drop tables retired from the schema. Idempotent (DROP … IF EXISTS), so it's
|
|
||||||
safe to run on every migration. Add entries here when a model is removed.
|
|
||||||
"""
|
|
||||||
drops = [
|
|
||||||
# v6: Ofsted Parent View feature removed
|
|
||||||
"DROP TABLE IF EXISTS marts.fact_parent_view CASCADE",
|
|
||||||
]
|
|
||||||
from sqlalchemy import text as sa_text
|
|
||||||
with engine.connect() as conn:
|
|
||||||
for stmt in drops:
|
|
||||||
try:
|
|
||||||
conn.execute(sa_text(stmt))
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: drop skipped ({e})")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def run_full_migration(geocode: bool = False) -> bool:
|
def run_full_migration(geocode: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Run a complete migration: drop all tables and reimport from CSV.
|
Run a complete migration: drop all tables and reimport from CSV.
|
||||||
@@ -498,9 +479,6 @@ def run_full_migration(geocode: bool = False) -> bool:
|
|||||||
print("Applying column additions to supplementary tables...")
|
print("Applying column additions to supplementary tables...")
|
||||||
_apply_schema_alterations()
|
_apply_schema_alterations()
|
||||||
|
|
||||||
print("Dropping retired tables...")
|
|
||||||
_apply_schema_drops()
|
|
||||||
|
|
||||||
print("\nLoading CSV data...")
|
print("\nLoading CSV data...")
|
||||||
df = load_csv_data(settings.data_dir)
|
df = load_csv_data(settings.data_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,29 @@ class FactOfstedInspection(Base):
|
|||||||
report_url = Column(Text)
|
report_url = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class FactParentView(Base):
|
||||||
|
"""Ofsted Parent View survey — latest per school."""
|
||||||
|
__tablename__ = "fact_parent_view"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
survey_date = Column(Date)
|
||||||
|
total_responses = Column(Integer)
|
||||||
|
q_happy_pct = Column(Float)
|
||||||
|
q_safe_pct = Column(Float)
|
||||||
|
q_behaviour_pct = Column(Float)
|
||||||
|
q_bullying_pct = Column(Float)
|
||||||
|
q_communication_pct = Column(Float)
|
||||||
|
q_progress_pct = Column(Float)
|
||||||
|
q_teaching_pct = Column(Float)
|
||||||
|
q_information_pct = Column(Float)
|
||||||
|
q_curriculum_pct = Column(Float)
|
||||||
|
q_future_pct = Column(Float)
|
||||||
|
q_leadership_pct = Column(Float)
|
||||||
|
q_wellbeing_pct = Column(Float)
|
||||||
|
q_recommend_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
class FactAdmissions(Base):
|
class FactAdmissions(Base):
|
||||||
"""School admissions — one row per URN per year."""
|
"""School admissions — one row per URN per year."""
|
||||||
__tablename__ = "fact_admissions"
|
__tablename__ = "fact_admissions"
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Regression tests for GET /api/schools/{urn}.
|
|
||||||
|
|
||||||
Schools with no performance rows (special post-16 institutions, sixth-form
|
|
||||||
centres, PRUs, brand-new schools) come back from the marts LEFT JOIN with
|
|
||||||
NaN in every numeric column. The endpoint must still serialize them — a NaN
|
|
||||||
that reaches Starlette's JSONResponse raises ValueError (allow_nan=False)
|
|
||||||
and the route 500s, which the frontend then renders as a 404.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
def _no_results_school_df() -> pd.DataFrame:
|
|
||||||
"""One school row as produced by the marts query for a school with no
|
|
||||||
performance data: GIAS/location fields partly populated, every
|
|
||||||
results-linked column NaN (including year)."""
|
|
||||||
return pd.DataFrame(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"urn": 150275,
|
|
||||||
"school_name": "West London Performing Arts Academy",
|
|
||||||
"phase": "Secondary",
|
|
||||||
"school_type": "Special post 16 institution",
|
|
||||||
"trust_name": None,
|
|
||||||
"religious_denomination": "Does not apply",
|
|
||||||
"gender": None,
|
|
||||||
"age_range": "16-25",
|
|
||||||
"admissions_policy": None,
|
|
||||||
"capacity": np.nan,
|
|
||||||
"gias_total_pupils": np.nan,
|
|
||||||
"headteacher_name": None,
|
|
||||||
"website": None,
|
|
||||||
"ofsted_grade": np.nan,
|
|
||||||
"local_authority": "Ealing",
|
|
||||||
"address": "268 Northfield Avenue, London, W5 4UB",
|
|
||||||
"postcode": "W5 4UB",
|
|
||||||
"latitude": 51.4986,
|
|
||||||
"longitude": -0.3148,
|
|
||||||
"year": np.nan,
|
|
||||||
"total_pupils": np.nan,
|
|
||||||
"eligible_pupils": np.nan,
|
|
||||||
"rwm_expected_pct": np.nan,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def client(monkeypatch):
|
|
||||||
from backend import app as app_module
|
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, "load_school_data", _no_results_school_df)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
app_module, "get_supplementary_data", lambda db, urn: {}
|
|
||||||
)
|
|
||||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_school_without_performance_rows_returns_200(client):
|
|
||||||
resp = client.get("/api/schools/150275")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_nan_gias_fields_serialize_as_null(client):
|
|
||||||
info = client.get("/api/schools/150275").json()["school_info"]
|
|
||||||
assert info["capacity"] is None
|
|
||||||
assert info["total_pupils"] is None
|
|
||||||
assert info["school_name"] == "West London Performing Arts Academy"
|
|
||||||
+1
-2
@@ -13,7 +13,7 @@ WHEN TO BUMP:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Current schema version - increment when models change
|
# Current schema version - increment when models change
|
||||||
SCHEMA_VERSION = 6
|
SCHEMA_VERSION = 5
|
||||||
|
|
||||||
# Changelog for documentation
|
# Changelog for documentation
|
||||||
SCHEMA_CHANGELOG = {
|
SCHEMA_CHANGELOG = {
|
||||||
@@ -22,5 +22,4 @@ SCHEMA_CHANGELOG = {
|
|||||||
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
|
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
|
||||||
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
|
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
|
||||||
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
|
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
|
||||||
6: "Removed the Ofsted Parent View feature: dropped fact_parent_view table and model",
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -79,7 +79,8 @@ fail the E2E gate. That's the point: staging absorbs the risk.
|
|||||||
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
||||||
itself from source, exercising the pipeline image end-to-end):
|
itself from source, exercising the pipeline image end-to-end):
|
||||||
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
|
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
|
||||||
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
|
`school_data_daily`, `school_data_monthly_ofsted`,
|
||||||
|
`school_data_monthly_parent_view`, then the manual-schedule
|
||||||
`school_data_annual_ees` and `school_data_annual_idaci`.
|
`school_data_annual_ees` and `school_data_annual_idaci`.
|
||||||
- First runs download from government sources (GIAS, Ofsted, EES, IDACI),
|
- First runs download from government sources (GIAS, Ofsted, EES, IDACI),
|
||||||
run dbt, and sync Typesense — expect the initial backfill to take a while.
|
run dbt, and sync Typesense — expect the initial backfill to take a while.
|
||||||
|
|||||||
@@ -25,16 +25,6 @@ test('home page loads with hero search', async ({ page }) => {
|
|||||||
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
// The geolocation shortcut lives inside the hero search card, right under the
|
|
||||||
// search input — not in a separate strip further down the page.
|
|
||||||
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
|
||||||
await expect(searchInput).toBeVisible();
|
|
||||||
const nearMe = page.getByRole('button', { name: /use my location/i });
|
|
||||||
await expect(nearMe).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('searching by name returns school results', async ({ page }) => {
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -60,34 +50,6 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
|
||||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
|
||||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
|
||||||
// serialization — which the frontend rendered as a 404 on every such SEO
|
|
||||||
// landing page. Find one via the search API (year === null marks "no
|
|
||||||
// performance rows") and assert its page renders.
|
|
||||||
const candidates: number[] = [];
|
|
||||||
for (const q of ['post 16', 'specialist college', 'sixth form']) {
|
|
||||||
const resp = await page.request.get(
|
|
||||||
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
|
|
||||||
);
|
|
||||||
if (!resp.ok()) continue;
|
|
||||||
const body = await resp.json();
|
|
||||||
for (const s of body.schools ?? []) {
|
|
||||||
if (s.year === null && s.urn) candidates.push(s.urn);
|
|
||||||
}
|
|
||||||
if (candidates.length) break;
|
|
||||||
}
|
|
||||||
test.skip(candidates.length === 0, 'no results-less school in this dataset');
|
|
||||||
|
|
||||||
const detail = await page.request.get(`/api/schools/${candidates[0]}`);
|
|
||||||
expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200);
|
|
||||||
|
|
||||||
await page.goto(`/school/${candidates[0]}`);
|
|
||||||
await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug
|
|
||||||
await expect(page.locator('h1').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
|
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
|
||||||
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
|
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
|
||||||
// CSS overlay. Simulate that by removing the API before any page script runs.
|
// CSS overlay. Simulate that by removing the API before any page script runs.
|
||||||
@@ -113,31 +75,6 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as
|
|||||||
await expect(openMap).toBeVisible();
|
await expect(openMap).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
|
||||||
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
|
||||||
// map's fullscreen button must fall back to a CSS overlay.
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
// @ts-expect-error deliberate API removal
|
|
||||||
delete Element.prototype.requestFullscreen;
|
|
||||||
});
|
|
||||||
|
|
||||||
await searchByName(page, 'B1 1BB');
|
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Switch to the map view, then open the map fullscreen.
|
|
||||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
|
||||||
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
|
||||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
|
||||||
await openFs.click();
|
|
||||||
|
|
||||||
// The button flips to its exit state once the overlay is up.
|
|
||||||
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
|
||||||
await expect(exitFs).toBeVisible();
|
|
||||||
await exitFs.click();
|
|
||||||
await expect(openFs).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||||
// Collect two school URNs from search results, then load the share URL
|
// Collect two school URNs from search results, then load the share URL
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
@@ -163,20 +100,15 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
|||||||
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
||||||
);
|
);
|
||||||
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
||||||
// Compare three schools, not two: a "primary" search can return all-through
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
// schools that classify as secondary, and the chips only appear for the
|
|
||||||
// active phase. With three schools across two phases, the auto-selected
|
|
||||||
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
|
|
||||||
expect(urns.length).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// The mobile chart legend renders one chip per school in the active phase.
|
// The mobile chart legend renders one chip per school inside the chart card.
|
||||||
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||||
const chips = chipGroup.getByRole('button');
|
const chips = chipGroup.getByRole('button');
|
||||||
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
await expect(chips).toHaveCount(2);
|
||||||
expect(await chips.count()).toBeGreaterThanOrEqual(2);
|
|
||||||
|
|
||||||
// Tapping a chip focuses that school's line; tapping again releases it.
|
// Tapping a chip focuses that school's line; tapping again releases it.
|
||||||
await chips.first().click();
|
await chips.first().click();
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Default backend URL for any server-side fetch during `next build`. The
|
# Build argument for FastAPI URL (used by Next.js rewrites at build time)
|
||||||
# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]),
|
|
||||||
# so the deployed container's env is what actually routes traffic.
|
|
||||||
ARG FASTAPI_URL=http://backend:80/api
|
ARG FASTAPI_URL=http://backend:80/api
|
||||||
ENV FASTAPI_URL=${FASTAPI_URL}
|
ENV FASTAPI_URL=${FASTAPI_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Runtime proxy for /api/* → the FastAPI backend.
|
|
||||||
*
|
|
||||||
* This replaces the old next.config.js `rewrites()` proxy, whose destination
|
|
||||||
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
|
|
||||||
* time. Because one frontend image is promoted staging→prod, a baked hostname
|
|
||||||
* forced every environment to name the backend identically; a mismatch (e.g.
|
|
||||||
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
|
|
||||||
*
|
|
||||||
* A route handler reads process.env.FASTAPI_URL on each request, so the same
|
|
||||||
* image adapts to whatever the backend is called in each environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
|
|
||||||
function backendBase(): string {
|
|
||||||
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hop-by-hop / length headers must not be copied across a proxy — undici has
|
|
||||||
// already decoded the body, so a stale content-encoding/length corrupts it.
|
|
||||||
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
|
||||||
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
||||||
|
|
||||||
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
|
||||||
const { path } = await ctx.params;
|
|
||||||
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
|
|
||||||
|
|
||||||
const headers = new Headers(req.headers);
|
|
||||||
headers.delete('host');
|
|
||||||
headers.delete('connection');
|
|
||||||
|
|
||||||
const init: RequestInit & { duplex?: 'half' } = {
|
|
||||||
method: req.method,
|
|
||||||
headers,
|
|
||||||
redirect: 'manual',
|
|
||||||
cache: 'no-store',
|
|
||||||
};
|
|
||||||
if (METHODS_WITH_BODY.has(req.method)) {
|
|
||||||
init.body = req.body;
|
|
||||||
init.duplex = 'half';
|
|
||||||
}
|
|
||||||
|
|
||||||
let upstream: Response;
|
|
||||||
try {
|
|
||||||
upstream = await fetch(target, init);
|
|
||||||
} catch (err) {
|
|
||||||
// e.g. DNS failure or connection refused — surface a clean 502 instead of
|
|
||||||
// an opaque proxy crash so callers can degrade gracefully.
|
|
||||||
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseHeaders = new Headers(upstream.headers);
|
|
||||||
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
|
|
||||||
|
|
||||||
return new NextResponse(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
statusText: upstream.statusText,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
handler as GET,
|
|
||||||
handler as HEAD,
|
|
||||||
handler as POST,
|
|
||||||
handler as PUT,
|
|
||||||
handler as PATCH,
|
|
||||||
handler as DELETE,
|
|
||||||
handler as OPTIONS,
|
|
||||||
};
|
|
||||||
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -189,6 +189,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
|
parentView={parent_view ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
senDetail={sen_detail ?? null}
|
senDetail={sen_detail ?? null}
|
||||||
@@ -202,6 +203,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
|
parentView={parent_view ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
|
||||||
*
|
|
||||||
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
|
||||||
* baking the backend host into the build, so one image works in every
|
|
||||||
* environment. robots.ts points crawlers here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
function backendOrigin(): string {
|
|
||||||
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
|
||||||
return base.replace(/\/api$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
let upstream: Response;
|
|
||||||
try {
|
|
||||||
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
|
||||||
} catch {
|
|
||||||
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await upstream.text();
|
|
||||||
return new NextResponse(body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.chips {
|
.chips {
|
||||||
/* Two chips per row so long school names don't crowd into a single
|
display: flex;
|
||||||
line; each chip fills its column and truncates with an ellipsis. */
|
flex-wrap: wrap;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -33,8 +31,8 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 40px;
|
min-height: 44px;
|
||||||
min-width: 0;
|
max-width: 100%;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, .12);
|
border: 1px solid rgba(0, 0, 0, .12);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -60,8 +58,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* min-width:0 lets the name shrink inside the grid cell so the
|
max-width: 9rem;
|
||||||
ellipsis kicks in instead of overflowing. */
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,91 +36,6 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchHint {
|
|
||||||
margin: 0.875rem 0 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-secondary, #5a554d);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchHint strong {
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.searchHint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeRow {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem 1.375rem;
|
|
||||||
background: var(--accent-teal, #2d7d7d);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, transform 0.15s ease;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn:hover:not(:disabled) {
|
|
||||||
background: #235f5f;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeSpinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: nearMeSpin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes nearMeSpin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geoError {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 340px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.nearMeBtn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,9 @@ interface FilterBarProps {
|
|||||||
filters: Filters;
|
filters: Filters;
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
resultFilters?: ResultFilters;
|
resultFilters?: ResultFilters;
|
||||||
// Geolocation "use my location" affordance, shown beside the hero search box.
|
|
||||||
// The state and handler live in HomeView (which owns the geolocation flow).
|
|
||||||
onNearMe?: () => void;
|
|
||||||
geoState?: "idle" | "requesting" | "error";
|
|
||||||
geoError?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBar({
|
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||||
filters,
|
|
||||||
isHero,
|
|
||||||
resultFilters,
|
|
||||||
onNearMe,
|
|
||||||
geoState = "idle",
|
|
||||||
geoError,
|
|
||||||
}: FilterBarProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -194,52 +182,6 @@ export function FilterBar({
|
|||||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isHero && (
|
|
||||||
<>
|
|
||||||
<p className={styles.searchHint}>
|
|
||||||
Search by <strong>school name</strong> — or use your{" "}
|
|
||||||
<strong>postcode</strong> for the nearest schools.
|
|
||||||
</p>
|
|
||||||
{onNearMe && (
|
|
||||||
<div className={styles.nearMeRow}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.nearMeBtn}
|
|
||||||
onClick={onNearMe}
|
|
||||||
disabled={geoState === "requesting"}
|
|
||||||
>
|
|
||||||
{geoState === "requesting" ? (
|
|
||||||
<>
|
|
||||||
<span className={styles.nearMeSpinner} aria-hidden="true" />
|
|
||||||
Locating you…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
width="15"
|
|
||||||
height="15"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
|
|
||||||
<circle cx="12" cy="9" r="2.5" />
|
|
||||||
</svg>
|
|
||||||
Use my location
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{geoError && (
|
|
||||||
<p className={styles.geoError} role="alert">
|
|
||||||
{geoError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
|
|||||||
@@ -369,16 +369,6 @@
|
|||||||
|
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
|
||||||
& Maths %"), which overflows a phone viewport — beside the view toggle it
|
|
||||||
ran off the right edge. Let it flex into the remaining space and shrink;
|
|
||||||
the selected label truncates instead of pushing past the screen. */
|
|
||||||
.sortSelect {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapViewContainer {
|
.mapViewContainer {
|
||||||
@@ -506,6 +496,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discoverySection {
|
||||||
|
padding: 0.5rem 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.375rem;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, transform 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:hover:not(:disabled) {
|
||||||
|
background: #235f5f;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtnSpinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: nearMeSpin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nearMeSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.geoError {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.quickSearches {
|
.quickSearches {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -284,11 +284,37 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
isHero={!isSearchActive}
|
isHero={!isSearchActive}
|
||||||
resultFilters={initialSchools.result_filters}
|
resultFilters={initialSchools.result_filters}
|
||||||
onNearMe={handleNearMe}
|
|
||||||
geoState={geoState}
|
|
||||||
geoError={geoError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Discovery section shown on landing page before any search */}
|
||||||
|
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||||
|
<div className={styles.discoverySection}>
|
||||||
|
<div className={styles.nearMeRow}>
|
||||||
|
<button
|
||||||
|
className={styles.nearMeBtn}
|
||||||
|
onClick={handleNearMe}
|
||||||
|
disabled={geoState === 'requesting'}
|
||||||
|
>
|
||||||
|
{geoState === 'requesting' ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||||||
|
Locating you…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||||
|
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||||||
|
<circle cx="12" cy="9" r="2.5"/>
|
||||||
|
</svg>
|
||||||
|
Schools near me
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Admissions countdown strip — only on landing page */}
|
{/* Admissions countdown strip — only on landing page */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<section className={styles.admissionsStrip}>
|
<section className={styles.admissionsStrip}>
|
||||||
|
|||||||
@@ -713,6 +713,18 @@
|
|||||||
margin: -0.5rem 0 1rem;
|
margin: -0.5rem 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Response count badge */
|
||||||
|
.responseBadge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-dm-sans), sans-serif;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.subSectionTitle {
|
.subSectionTitle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -720,6 +732,18 @@
|
|||||||
margin: 1.25rem 0 0.75rem;
|
margin: 1.25rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Parent recommendation line in Ofsted section */
|
||||||
|
.parentRecommendLine {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentRecommendLine strong {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Metrics Grid & Cards */
|
/* Metrics Grid & Cards */
|
||||||
.metricsGrid {
|
.metricsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1070,6 +1094,49 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Parent View */
|
||||||
|
.parentViewGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex: 0 0 18rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewBar {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewPct {
|
||||||
|
flex: 0 0 2.75rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
/* Admissions badge — uses unified status colours */
|
/* Admissions badge — uses unified status colours */
|
||||||
.admissionsBadge {
|
.admissionsBadge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1202,6 +1269,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.parentViewRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex: none;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewBar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewPct {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
|||||||
import { MetricTooltip } from './MetricTooltip';
|
import { MetricTooltip } from './MetricTooltip';
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
@@ -63,6 +63,7 @@ interface SchoolDetailViewProps {
|
|||||||
yearlyData: SchoolResult[];
|
yearlyData: SchoolResult[];
|
||||||
absenceData: AbsenceData | null;
|
absenceData: AbsenceData | null;
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
|
parentView: OfstedParentView | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
@@ -74,7 +75,7 @@ interface SchoolDetailViewProps {
|
|||||||
|
|
||||||
export function SchoolDetailView({
|
export function SchoolDetailView({
|
||||||
schoolInfo, yearlyData, absenceData,
|
schoolInfo, yearlyData, absenceData,
|
||||||
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
||||||
}: SchoolDetailViewProps) {
|
}: SchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
@@ -233,6 +234,8 @@ export function SchoolDetailView({
|
|||||||
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 && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||||
|
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
|
||||||
|
navItems.push({ id: 'parents', label: 'Parents' });
|
||||||
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' });
|
||||||
@@ -546,6 +549,11 @@ export function SchoolDetailView({
|
|||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<p className={styles.parentRecommendLine}>
|
||||||
|
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* ── Old OEIF layout ── */
|
/* ── Old OEIF layout ── */
|
||||||
@@ -564,6 +572,11 @@ export function SchoolDetailView({
|
|||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
||||||
</p>
|
</p>
|
||||||
|
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<p className={styles.parentRecommendLine}>
|
||||||
|
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{oeifAllSameGrade ? (
|
{oeifAllSameGrade ? (
|
||||||
<p className={styles.ofstedAllSame}>
|
<p className={styles.ofstedAllSame}>
|
||||||
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.
|
||||||
@@ -1116,6 +1129,42 @@ export function SchoolDetailView({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* What Parents Say */}
|
||||||
|
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<section id="parents" className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
What Parents Say
|
||||||
|
<span className={styles.responseBadge}>
|
||||||
|
{parentView.total_responses.toLocaleString()} responses
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
From the Ofsted Parent View survey — parents share their experience of this school.
|
||||||
|
</p>
|
||||||
|
<div className={styles.parentViewGrid}>
|
||||||
|
{[
|
||||||
|
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
||||||
|
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
||||||
|
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
|
||||||
|
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
|
||||||
|
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
|
||||||
|
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
|
||||||
|
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
|
||||||
|
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
|
||||||
|
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
|
||||||
|
].filter(q => q.pct != null).map(({ label, pct }) => (
|
||||||
|
<div key={label} className={styles.parentViewRow}>
|
||||||
|
<span className={styles.parentViewLabel}>{label}</span>
|
||||||
|
<div className={styles.parentViewBar}>
|
||||||
|
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* School Life */}
|
{/* School Life */}
|
||||||
{hasSchoolLife && (
|
{hasSchoolLife && (
|
||||||
<section id="school-life" className={styles.card}>
|
<section id="school-life" className={styles.card}>
|
||||||
|
|||||||
@@ -10,15 +10,6 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
|
|
||||||
can't promote the element, so pin it over the page ourselves. Above the
|
|
||||||
comparison toast (3000) and the bottom nav; below modals (9999+). */
|
|
||||||
.mapWrapper.fsFallback {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreenBtn {
|
.fullscreenBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.625rem;
|
top: 0.625rem;
|
||||||
|
|||||||
@@ -33,52 +33,22 @@ interface SchoolMapProps {
|
|||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
|
||||||
// overlay driven by state instead of the Fullscreen API.
|
|
||||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
|
||||||
const isFullscreen = nativeFullscreen || fallbackFullscreen;
|
|
||||||
|
|
||||||
// Sync state with browser fullscreen events (e.g. Escape key)
|
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Lock body scroll while the fallback overlay is up.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fallbackFullscreen) return;
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => { document.body.style.overflow = prev; };
|
|
||||||
}, [fallbackFullscreen]);
|
|
||||||
|
|
||||||
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
|
|
||||||
// one; the CSS fallback overlay changes size without a resize event, so nudge
|
|
||||||
// Leaflet after the layout settles or the map fills only part of the screen.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [isFullscreen]);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.exitFullscreen().catch(() => {});
|
wrapperRef.current?.requestFullscreen();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fallbackFullscreen) {
|
|
||||||
setFallbackFullscreen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = wrapperRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (el.requestFullscreen) {
|
|
||||||
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
|
||||||
} else {
|
} else {
|
||||||
setFallbackFullscreen(true);
|
document.exitFullscreen();
|
||||||
}
|
}
|
||||||
}, [fallbackFullscreen]);
|
}, []);
|
||||||
|
|
||||||
// Calculate center if not provided
|
// Calculate center if not provided
|
||||||
const mapCenter: [number, number] = center || (() => {
|
const mapCenter: [number, number] = center || (() => {
|
||||||
@@ -94,7 +64,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={styles.fullscreenBtn}
|
className={styles.fullscreenBtn}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -383,6 +383,17 @@
|
|||||||
margin: 1.25rem 0 0.75rem;
|
margin: 1.25rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.responseBadge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-dm-sans), sans-serif;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Progress 8 suspension banner ───────────────────── */
|
/* ── Progress 8 suspension banner ───────────────────── */
|
||||||
.p8Banner {
|
.p8Banner {
|
||||||
background: rgba(180, 120, 0, 0.1);
|
background: rgba(180, 120, 0, 0.1);
|
||||||
@@ -653,6 +664,60 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Parent View ─────────────────────────────────────── */
|
||||||
|
.parentRecommendLine {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentRecommendLine strong {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex: 0 0 18rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewBar {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewPct {
|
||||||
|
flex: 0 0 2.75rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Admissions ──────────────────────────────────────── */
|
/* ── Admissions ──────────────────────────────────────── */
|
||||||
.admissionsTypeBadge {
|
.admissionsTypeBadge {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1070,6 +1135,10 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex-basis: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ofstedReportLink {
|
.ofstedReportLink {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1082,6 +1151,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.parentViewRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex: none;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewBar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewPct {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.metricsGrid {
|
.metricsGrid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const PerformanceChart = dynamic(
|
|||||||
);
|
);
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
@@ -65,6 +65,7 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
yearlyData: SchoolResult[];
|
yearlyData: SchoolResult[];
|
||||||
absenceData: AbsenceData | null;
|
absenceData: AbsenceData | null;
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
|
parentView: OfstedParentView | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
senDetail: SenDetail | null;
|
senDetail: SenDetail | null;
|
||||||
@@ -75,7 +76,7 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
export function SecondarySchoolDetailView({
|
||||||
schoolInfo, yearlyData,
|
schoolInfo, yearlyData,
|
||||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||||
}: SecondarySchoolDetailViewProps) {
|
}: SecondarySchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Hero map — the "View on map" link opens its fullscreen view.
|
// Hero map — the "View on map" link opens its fullscreen view.
|
||||||
@@ -100,6 +101,7 @@ export function SecondarySchoolDetailView({
|
|||||||
|
|
||||||
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||||
|
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
|
||||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||||||
@@ -157,6 +159,7 @@ export function SecondarySchoolDetailView({
|
|||||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||||
|
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
|
||||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
|
|
||||||
@@ -432,6 +435,11 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{hasParents && (
|
||||||
|
<p className={styles.parentRecommendLine}>
|
||||||
|
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -767,6 +775,42 @@ export function SecondarySchoolDetailView({
|
|||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{/* ── Parent View ────────────────────────────────── */}
|
||||||
|
{hasParents && parentView && (
|
||||||
|
<section id="parents" className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
What Parents Say
|
||||||
|
<span className={styles.responseBadge}>
|
||||||
|
{parentView.total_responses!.toLocaleString()} responses
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
From the Ofsted Parent View survey — parents share their experience of this school.
|
||||||
|
</p>
|
||||||
|
<div className={styles.parentViewGrid}>
|
||||||
|
{[
|
||||||
|
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
||||||
|
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
||||||
|
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
|
||||||
|
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
|
||||||
|
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
|
||||||
|
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
|
||||||
|
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
|
||||||
|
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
|
||||||
|
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
|
||||||
|
].filter(q => q.pct != null).map(({ label, pct }) => (
|
||||||
|
<div key={label} className={styles.parentViewRow}>
|
||||||
|
<span className={styles.parentViewLabel}>{label}</span>
|
||||||
|
<div className={styles.parentViewBar}>
|
||||||
|
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
{/* ── Wellbeing ──────────────────────────────────── */}
|
||||||
{hasWellbeing && (
|
{hasWellbeing && (
|
||||||
<section id="wellbeing" className={styles.card}>
|
<section id="wellbeing" className={styles.card}>
|
||||||
|
|||||||
@@ -99,6 +99,25 @@ export interface OfstedInspection {
|
|||||||
rc_sixth_form: number | null;
|
rc_sixth_form: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OfstedParentView {
|
||||||
|
survey_date: string | null;
|
||||||
|
total_responses: number | null;
|
||||||
|
q_happy_pct: number | null;
|
||||||
|
q_safe_pct: number | null;
|
||||||
|
q_behaviour_pct: number | null;
|
||||||
|
q_bullying_pct: number | null;
|
||||||
|
q_communication_pct: number | null;
|
||||||
|
q_progress_pct: number | null;
|
||||||
|
q_teaching_pct: number | null;
|
||||||
|
q_information_pct: number | null;
|
||||||
|
q_curriculum_pct: number | null;
|
||||||
|
q_future_pct: number | null;
|
||||||
|
q_leadership_pct: number | null;
|
||||||
|
q_wellbeing_pct: number | null;
|
||||||
|
q_recommend_pct: number | null;
|
||||||
|
q_sen_pct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SchoolCensus {
|
export interface SchoolCensus {
|
||||||
year: number;
|
year: number;
|
||||||
total_pupils: number | null;
|
total_pupils: number | null;
|
||||||
@@ -293,6 +312,7 @@ export interface SchoolDetailsResponse {
|
|||||||
absence_data: AbsenceData | null;
|
absence_data: AbsenceData | null;
|
||||||
// Supplementary data (null until Kestra populates)
|
// Supplementary data (null until Kestra populates)
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
|
parent_view: OfstedParentView | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ const nextConfig = {
|
|||||||
// Enable standalone output for Docker
|
// Enable standalone output for Docker
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|
||||||
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
|
// API Proxy to FastAPI backend
|
||||||
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
|
async rewrites() {
|
||||||
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
|
const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api';
|
||||||
// instead of being baked into the build.
|
const backendUrl = apiUrl.replace(/\/api$/, '');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${apiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/sitemap.xml',
|
||||||
|
destination: `${backendUrl}/sitemap.xml`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ RUN pip install --no-cache-dir \
|
|||||||
./plugins/extractors/tap-uk-gias \
|
./plugins/extractors/tap-uk-gias \
|
||||||
./plugins/extractors/tap-uk-ees \
|
./plugins/extractors/tap-uk-ees \
|
||||||
./plugins/extractors/tap-uk-ofsted \
|
./plugins/extractors/tap-uk-ofsted \
|
||||||
|
./plugins/extractors/tap-uk-parent-view \
|
||||||
./plugins/extractors/tap-uk-fbit \
|
./plugins/extractors/tap-uk-fbit \
|
||||||
./plugins/extractors/tap-uk-idaci
|
./plugins/extractors/tap-uk-idaci
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,31 @@ with DAG(
|
|||||||
extract_ees_group >> dbt_build_ees >> sync_typesense_ees
|
extract_ees_group >> dbt_build_ees >> sync_typesense_ees
|
||||||
|
|
||||||
|
|
||||||
|
# ── Monthly DAG (Parent View) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="school_data_monthly_parent_view",
|
||||||
|
default_args=default_args,
|
||||||
|
description="Monthly Ofsted Parent View extraction and transform",
|
||||||
|
schedule="0 3 1 * *",
|
||||||
|
start_date=datetime(2025, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
tags=["school-compare", "monthly"],
|
||||||
|
) as monthly_parent_view_dag:
|
||||||
|
|
||||||
|
extract_parent_view = BashOperator(
|
||||||
|
task_id="extract_parent_view",
|
||||||
|
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-parent-view target-postgres",
|
||||||
|
)
|
||||||
|
|
||||||
|
dbt_build_parent_view = BashOperator(
|
||||||
|
task_id="dbt_build",
|
||||||
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_parent_view+ fact_parent_view+",
|
||||||
|
)
|
||||||
|
|
||||||
|
extract_parent_view >> dbt_build_parent_view
|
||||||
|
|
||||||
|
|
||||||
# ── Annual DAG (IDACI Deprivation) ────────────────────────────────────
|
# ── Annual DAG (IDACI Deprivation) ────────────────────────────────────
|
||||||
|
|
||||||
with DAG(
|
with DAG(
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ plugins:
|
|||||||
kind: string
|
kind: string
|
||||||
description: Ofsted Management Information download URL
|
description: Ofsted Management Information download URL
|
||||||
|
|
||||||
|
- name: tap-uk-parent-view
|
||||||
|
namespace: uk_parent_view
|
||||||
|
pip_url: ./plugins/extractors/tap-uk-parent-view
|
||||||
|
executable: tap-uk-parent-view
|
||||||
|
|
||||||
- name: tap-uk-fbit
|
- name: tap-uk-fbit
|
||||||
namespace: uk_fbit
|
namespace: uk_fbit
|
||||||
pip_url: ./plugins/extractors/tap-uk-fbit
|
pip_url: ./plugins/extractors/tap-uk-fbit
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tap-uk-parent-view"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Singer tap for UK Ofsted Parent View survey data"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"singer-sdk~=0.53",
|
||||||
|
"requests>=2.31",
|
||||||
|
"pandas>=2.0",
|
||||||
|
"openpyxl>=3.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tap-uk-parent-view = "tap_uk_parent_view.tap:TapUKParentView.cli"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""tap-uk-parent-view: Singer tap for Ofsted Parent View survey data."""
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Parent View Singer tap — extracts survey data from Ofsted Parent View open data portal."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from singer_sdk import Stream, Tap
|
||||||
|
from singer_sdk import typing as th
|
||||||
|
|
||||||
|
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
|
||||||
|
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
|
||||||
|
strongly = row.get(f"{q_col_base} - Strongly agree %") or row.get(f"{q_col_base} - Strongly Agree %")
|
||||||
|
agree = row.get(f"{q_col_base} - Agree %")
|
||||||
|
try:
|
||||||
|
total = 0.0
|
||||||
|
if pd.notna(strongly):
|
||||||
|
total += float(strongly)
|
||||||
|
if pd.notna(agree):
|
||||||
|
total += float(agree)
|
||||||
|
return round(total, 1) if total > 0 else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ParentViewStream(Stream):
|
||||||
|
"""Stream: Parent View survey responses per school."""
|
||||||
|
|
||||||
|
name = "parent_view"
|
||||||
|
primary_keys = ["urn"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("urn", th.IntegerType, required=True),
|
||||||
|
th.Property("survey_date", th.StringType),
|
||||||
|
th.Property("total_responses", th.IntegerType),
|
||||||
|
th.Property("q_happy_pct", th.NumberType),
|
||||||
|
th.Property("q_safe_pct", th.NumberType),
|
||||||
|
th.Property("q_behaviour_pct", th.NumberType),
|
||||||
|
th.Property("q_bullying_pct", th.NumberType),
|
||||||
|
th.Property("q_communication_pct", th.NumberType),
|
||||||
|
th.Property("q_progress_pct", th.NumberType),
|
||||||
|
th.Property("q_teaching_pct", th.NumberType),
|
||||||
|
th.Property("q_information_pct", th.NumberType),
|
||||||
|
th.Property("q_curriculum_pct", th.NumberType),
|
||||||
|
th.Property("q_future_pct", th.NumberType),
|
||||||
|
th.Property("q_leadership_pct", th.NumberType),
|
||||||
|
th.Property("q_wellbeing_pct", th.NumberType),
|
||||||
|
th.Property("q_recommend_pct", th.NumberType),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def _discover_download_url(self) -> str:
|
||||||
|
"""Scrape the open data page for the download link."""
|
||||||
|
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
urls = re.findall(r'href="([^"]+\.(?:xlsx|csv|zip))"', resp.text, re.IGNORECASE)
|
||||||
|
if not urls:
|
||||||
|
msg = "No download link found on Parent View open data page"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
url = urls[0]
|
||||||
|
if not url.startswith("http"):
|
||||||
|
url = "https://parentview.ofsted.gov.uk" + url
|
||||||
|
return url
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
url = self._discover_download_url()
|
||||||
|
self.logger.info("Downloading Parent View data: %s", url)
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
if url.endswith(".xlsx"):
|
||||||
|
df = pd.read_excel(io.BytesIO(resp.content))
|
||||||
|
else:
|
||||||
|
df = pd.read_csv(
|
||||||
|
io.BytesIO(resp.content),
|
||||||
|
encoding="latin-1",
|
||||||
|
low_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalise URN column
|
||||||
|
urn_col = next((c for c in df.columns if c.strip().upper() == "URN"), None)
|
||||||
|
if not urn_col:
|
||||||
|
self.logger.error("URN column not found. Columns: %s", list(df.columns)[:20])
|
||||||
|
return
|
||||||
|
|
||||||
|
df.rename(columns={urn_col: "urn"}, inplace=True)
|
||||||
|
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||||
|
df = df.dropna(subset=["urn"])
|
||||||
|
|
||||||
|
# Find total responses column
|
||||||
|
resp_col = next(
|
||||||
|
(c for c in df.columns if "total" in c.lower() and "respon" in c.lower()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
today = date.today().isoformat()
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
urn = int(row["urn"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = None
|
||||||
|
if resp_col and pd.notna(row.get(resp_col)):
|
||||||
|
try:
|
||||||
|
total = int(row[resp_col])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"urn": urn,
|
||||||
|
"survey_date": today,
|
||||||
|
"total_responses": total,
|
||||||
|
"q_happy_pct": _positive_pct(row, "Q1"),
|
||||||
|
"q_safe_pct": _positive_pct(row, "Q2"),
|
||||||
|
"q_behaviour_pct": _positive_pct(row, "Q3"),
|
||||||
|
"q_bullying_pct": _positive_pct(row, "Q4"),
|
||||||
|
"q_communication_pct": _positive_pct(row, "Q5"),
|
||||||
|
"q_progress_pct": _positive_pct(row, "Q7"),
|
||||||
|
"q_teaching_pct": _positive_pct(row, "Q8"),
|
||||||
|
"q_information_pct": _positive_pct(row, "Q9"),
|
||||||
|
"q_curriculum_pct": _positive_pct(row, "Q10"),
|
||||||
|
"q_future_pct": _positive_pct(row, "Q11"),
|
||||||
|
"q_leadership_pct": _positive_pct(row, "Q12"),
|
||||||
|
"q_wellbeing_pct": _positive_pct(row, "Q13"),
|
||||||
|
"q_recommend_pct": _positive_pct(row, "Q14"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TapUKParentView(Tap):
|
||||||
|
"""Singer tap for UK Ofsted Parent View."""
|
||||||
|
|
||||||
|
name = "tap-uk-parent-view"
|
||||||
|
config_jsonschema = th.PropertiesList(
|
||||||
|
th.Property("download_url", th.StringType, description="Direct URL to Parent View data file"),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def discover_streams(self):
|
||||||
|
return [ParentViewStream(self)]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
TapUKParentView.cli()
|
||||||
@@ -105,6 +105,12 @@ models:
|
|||||||
- name: year
|
- name: year
|
||||||
tests: [not_null]
|
tests: [not_null]
|
||||||
|
|
||||||
|
- name: fact_parent_view
|
||||||
|
description: Parent View survey responses
|
||||||
|
columns:
|
||||||
|
- name: urn
|
||||||
|
tests: [not_null]
|
||||||
|
|
||||||
- name: fact_ks2_national_averages
|
- name: fact_ks2_national_averages
|
||||||
description: Official DfE KS2 national headline averages — one row per academic year
|
description: Official DfE KS2 national headline averages — one row per academic year
|
||||||
columns:
|
columns:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Mart: Parent View survey responses — one row per URN (latest survey)
|
||||||
|
|
||||||
|
select
|
||||||
|
urn,
|
||||||
|
survey_date,
|
||||||
|
total_responses,
|
||||||
|
q_happy_pct,
|
||||||
|
q_safe_pct,
|
||||||
|
q_behaviour_pct,
|
||||||
|
q_bullying_pct,
|
||||||
|
q_communication_pct,
|
||||||
|
q_progress_pct,
|
||||||
|
q_teaching_pct,
|
||||||
|
q_information_pct,
|
||||||
|
q_curriculum_pct,
|
||||||
|
q_future_pct,
|
||||||
|
q_leadership_pct,
|
||||||
|
q_wellbeing_pct,
|
||||||
|
q_recommend_pct
|
||||||
|
from {{ ref('stg_parent_view') }}
|
||||||
@@ -53,6 +53,9 @@ sources:
|
|||||||
|
|
||||||
# Phonics: no school-level data on EES (only national/LA level)
|
# Phonics: no school-level data on EES (only national/LA level)
|
||||||
|
|
||||||
|
- name: parent_view
|
||||||
|
description: Ofsted Parent View survey responses
|
||||||
|
|
||||||
- name: fbit_finance
|
- name: fbit_finance
|
||||||
description: Financial benchmarking data from FBIT API
|
description: Financial benchmarking data from FBIT API
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Staging model: Ofsted Parent View survey responses
|
||||||
|
-- The tap computes positive percentages (Strongly agree + Agree) per question.
|
||||||
|
|
||||||
|
with source as (
|
||||||
|
select * from {{ source('raw', 'parent_view') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
renamed as (
|
||||||
|
select
|
||||||
|
cast(urn as integer) as urn,
|
||||||
|
cast(survey_date as date) as survey_date,
|
||||||
|
cast(total_responses as integer) as total_responses,
|
||||||
|
cast(q_happy_pct as numeric) as q_happy_pct,
|
||||||
|
cast(q_safe_pct as numeric) as q_safe_pct,
|
||||||
|
cast(q_behaviour_pct as numeric) as q_behaviour_pct,
|
||||||
|
cast(q_bullying_pct as numeric) as q_bullying_pct,
|
||||||
|
cast(q_communication_pct as numeric) as q_communication_pct,
|
||||||
|
cast(q_progress_pct as numeric) as q_progress_pct,
|
||||||
|
cast(q_teaching_pct as numeric) as q_teaching_pct,
|
||||||
|
cast(q_information_pct as numeric) as q_information_pct,
|
||||||
|
cast(q_curriculum_pct as numeric) as q_curriculum_pct,
|
||||||
|
cast(q_future_pct as numeric) as q_future_pct,
|
||||||
|
cast(q_leadership_pct as numeric) as q_leadership_pct,
|
||||||
|
cast(q_wellbeing_pct as numeric) as q_wellbeing_pct,
|
||||||
|
cast(q_recommend_pct as numeric) as q_recommend_pct
|
||||||
|
from source
|
||||||
|
where urn is not null
|
||||||
|
)
|
||||||
|
|
||||||
|
select * from renamed
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Retire the Ofsted Parent View feature (schema v6).
|
|
||||||
-- The marts schema is dbt-owned; deleting the dbt model stops the table being
|
|
||||||
-- rebuilt but does not drop the existing relation, so apply this directly
|
|
||||||
-- against the staging and production marts databases.
|
|
||||||
DROP TABLE IF EXISTS marts.fact_parent_view CASCADE;
|
|
||||||
Reference in New Issue
Block a user