Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 d0100cce69 feat(ci): comment-triggered PR fix-ups via @claude
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m30s
Commenting '@claude <instruction>' on a PR runs headless Claude Code on the
PR branch (subscription auth), pushes the resulting commit — re-running the
PR checks — and replies with a summary. Owner-only trigger; runs unsandboxed
inside the ephemeral runner container per explicit maintainer sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 16:26:09 +01:00
51 changed files with 962 additions and 947 deletions
+1 -4
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
name: PR Comment Agent
on:
issue_comment:
types: [created]
jobs:
ai-fixup:
name: Claude Fix-up (@claude comment)
runs-on: ubuntu-latest
# Only PR comments, only from the repo owner, only when addressed to @claude.
# The owner guard matters: the job pushes code to the PR branch.
if: >-
gitea.event.issue.pull_request &&
startsWith(gitea.event.comment.body, '@claude') &&
gitea.event.comment.user.login == gitea.repository_owner
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Apply the requested fix-up
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.issue.number }}
COMMENT_BODY: ${{ gitea.event.comment.body }}
# Claude Code runs as root inside the runner container; this flag
# acknowledges the container *is* the sandbox.
IS_SANDBOX: "1"
run: python scripts/ci/ai_fixup.py
+1 -1
View File
@@ -1,2 +1,2 @@
venv venv
__pycache__/ backend/__pycache__
+7 -18
View File
@@ -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 [],
@@ -842,10 +834,7 @@ async def get_rankings(
request: Request, request: Request,
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50), metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query( year: Optional[int] = Query(
None, None, description="Specific year (defaults to most recent)", ge=2000, le=2100
description="Academic year code, e.g. 201819 (defaults to most recent)",
ge=2000,
le=210100,
), ),
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"), limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
local_authority: Optional[str] = Query( local_authority: Optional[str] = Query(
+25 -1
View File
@@ -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"] = (
-22
View File
@@ -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)
+23
View File
@@ -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"
View File
-71
View File
@@ -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
View File
@@ -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",
} }
+20 -1
View File
@@ -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.
@@ -129,3 +130,21 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), and fails the check only when a finding is rated needed), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are **severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge. informational and never block a merge.
## Comment-triggered fix-ups (@claude)
Comment `@claude <instruction>` on any PR and `.gitea/workflows/pr-comment.yml`
runs `scripts/ci/ai_fixup.py`: it checks out the PR branch, hands the
instruction to headless Claude Code (same subscription auth as the reviewer),
commits and pushes whatever changed, and replies on the PR with a summary.
The push re-runs the PR checks automatically.
Guard rails:
- Only comments from the **repo owner** trigger it (the job pushes code).
- Only comments starting with `@claude` — the bot's own replies never re-trigger.
- Each comment is one full agentic session on the Claude subscription; batch
related asks into one comment rather than several small ones.
Caveat: if the checks don't re-run after the bot's push, Gitea is suppressing
workflows for pushes made with the run token — create a personal access token
secret and swap it in for the push, or re-run the checks manually.
-140
View File
@@ -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,84 +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 }) => {
// 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.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
const openMap = page.getByRole('button', { name: 'Open full map' });
await expect(openMap).toBeVisible({ timeout: 15_000 });
await openMap.click();
const closeMap = page.getByRole('button', { name: 'Close map' });
await expect(closeMap).toBeVisible();
await closeMap.click();
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');
@@ -154,37 +66,6 @@ test('comparing two schools shows both side by side', async ({ page }) => {
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
}); });
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
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
// 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 expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school in the active phase.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
expect(await chips.count()).toBeGreaterThanOrEqual(2);
// Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('rankings page loads a populated table', async ({ page }) => { test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings'); await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
@@ -192,24 +73,3 @@ test('rankings page loads a populated table', async ({ page }) => {
await expect(rows.first()).toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5); expect(await rows.count()).toBeGreaterThan(5);
}); });
test('rankings stay populated after picking a specific year', async ({ page }) => {
// Years are academic-year codes (e.g. 201819); the API must accept them
// as the `year` query param rather than rejecting with a 422.
await page.goto('/rankings');
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
// Pick the last option — the most recent explicit year. The default view
// already proved this year has rows, so an empty table after selecting it
// can only mean the year param was rejected. (The oldest year is no good
// here: staging doesn't always carry the full data history.)
const yearValue = await yearSelect.locator('option').last().getAttribute('value');
expect(yearValue).toBeTruthy();
await yearSelect.selectOption(yearValue!);
await page.waitForURL(/year=/);
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
+1 -3
View File
@@ -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}
-53
View File
@@ -9,8 +9,6 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
metricKind,
computeYBounds,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -161,54 +159,3 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofstedPending'); expect(badge.cssClass).toBe('ofstedPending');
}); });
}); });
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
-75
View File
@@ -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,
};
+3 -1
View File
@@ -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 ?? []}
-32
View File
@@ -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' },
});
}
@@ -1,67 +0,0 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
parent .chartContainer, whose fixed height drives Chart.js sizing via
maintainAspectRatio: false. */
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.canvasBox {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
Chart.js's built-in legend (with per-school point shapes). */
.chips {
display: none;
}
@media (max-width: 640px) {
.chips {
/* Two chips per row so long school names don't crowd into a single
line; each chip fills its column and truncates with an ellipsis. */
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
min-width: 0;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
}
.chipDot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
}
.chipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* min-width:0 lets the name shrink inside the grid cell so the
ellipsis kicks in instead of overflowing. */
min-width: 0;
}
}
+45 -116
View File
@@ -1,82 +1,47 @@
/** /**
* ComparisonChart Component * ComparisonChart Component
* Multi-school comparison chart using Chart.js. * Multi-school comparison chart using Chart.js
*
* Desktop: built-in legend (point-style markers double as per-school shapes).
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
* of a chip row above the canvas; tapping a chip highlights that school's
* line and dims the rest. The y-axis auto-fits the data on all viewports so
* clustered schools stay distinguishable.
*/ */
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js'; import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types'; import type { ComparisonData } from '@/lib/types';
import { import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
CHART_COLORS,
CHART_TEXT_COLORS,
computeYBounds,
formatAcademicYear,
metricKind,
rgbToRgba,
} from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './ComparisonChart.module.css';
interface ComparisonChartProps { interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>; comparisonData: Record<string, ComparisonData>;
/** Ordered as displayed in the school cards, so colours match by index. */
schools: Array<{ urn: number; school_name: string }>;
metric: string; metric: string;
metricLabel: string; metricLabel: string;
} }
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// converging lines stay tellable apart without relying on hue alone. // Get all schools and their data
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; const schools = Object.entries(comparisonData);
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
const urnKey = schools.map((s) => s.urn).join(',');
useEffect(() => {
setFocusedUrn(null);
}, [urnKey]);
if (schools.length === 0) { if (schools.length === 0) {
return <div>No data available</div>; return <div>No data available</div>;
} }
// Union of years across all schools — coverage differs between them. // Get years from first school (assuming all schools have same years)
const years = [ const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => { // Create datasets for each school
const data = comparisonData[String(school.urn)]; const datasets = schools.map(([urn, data], index) => {
const schoolInfo = data.school_info;
const color = CHART_COLORS[index % CHART_COLORS.length]; const color = CHART_COLORS[index % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return { return {
label: school.school_name, label: schoolInfo.school_name,
data: years.map((year) => { data: years.map((year) => {
const yearData = data?.yearly_data.find((d) => d.year === year); const yearData = data.yearly_data.find((d) => d.year === year);
if (!yearData) return null; if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null; return yearData[metric as keyof typeof yearData] as number | null;
}), }),
borderColor: dimmed ? rgbToRgba(color, 0.2) : color, borderColor: color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1), backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3, tension: 0.3,
spanGaps: true, spanGaps: true,
}; };
@@ -87,11 +52,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
datasets, datasets,
}; };
const kind = metricKind(metric); // Determine if metric is a progress score or percentage
const yBounds = computeYBounds( const isProgressScore = metric.includes('progress');
datasets.flatMap((ds) => ds.data as Array<number | null>), const isPercentage = metric.includes('pct') || metric.includes('rate');
kind,
);
const options: ChartOptions<'line'> = { const options: ChartOptions<'line'> = {
responsive: true, responsive: true,
@@ -102,7 +65,6 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
}, },
plugins: { plugins: {
legend: { legend: {
display: !isMobile,
position: 'top' as const, position: 'top' as const,
labels: { labels: {
usePointStyle: true, usePointStyle: true,
@@ -112,22 +74,26 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
}, },
}, },
}, },
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: { title: {
display: false, display: true,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: isMobile ? 10 : 12, padding: 12,
titleFont: { titleFont: {
size: isMobile ? 12 : 14, size: 14,
}, },
bodyFont: { bodyFont: {
size: isMobile ? 11 : 13, size: 13,
}, },
usePointStyle: true,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: { callbacks: {
label: function (context) { label: function (context) {
let label = context.dataset.label || ''; let label = context.dataset.label || '';
@@ -135,7 +101,13 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : ''); if (isProgressScore) {
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
} else { } else {
label += 'N/A'; label += 'N/A';
} }
@@ -149,18 +121,17 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
title: { title: {
display: !isMobile, display: true,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
...yBounds, ...(isPercentage && {
ticks: { min: 0,
font: { size: isMobile ? 10 : 12 }, max: 100,
...(isMobile && { maxTicksLimit: 5 }), }),
},
grid: { grid: {
color: 'rgba(0, 0, 0, 0.05)', color: 'rgba(0, 0, 0, 0.05)',
}, },
@@ -170,58 +141,16 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
display: false, display: false,
}, },
title: { title: {
display: !isMobile, display: true,
text: 'Year', text: 'Year',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
}, },
}, },
}; };
const toggleFocus = (urn: number) => { return <Line data={chartData} options={options} />;
const next = focusedUrn === urn ? null : urn;
setFocusedUrn(next);
if (next !== null) track('compare_focus_school', { urn: next });
};
return (
<div className={styles.wrapper}>
{/* Mobile legend + focus control; a single series needs no legend. */}
{schools.length > 1 && (
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
{schools.map((school, index) => (
<button
key={school.urn}
type="button"
className={styles.chip}
aria-pressed={focusedUrn === school.urn}
onClick={() => toggleFocus(school.urn)}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span
className={styles.chipName}
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
>
{school.school_name}
</span>
</button>
))}
</div>
)}
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
</div>
);
} }
@@ -454,10 +454,7 @@
} }
.chartContainer { .chartContainer {
/* Taller than desktop's proportion would suggest: the chip legend row height: 300px;
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
} }
.comparisonTable { .comparisonTable {
+1 -4
View File
@@ -111,10 +111,8 @@ export function ComparisonView({
setComparisonData(data.comparison); setComparisonData(data.comparison);
}) })
.catch((err) => { .catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err); console.error('Failed to fetch comparison:', err);
setComparisonData(null);
}); });
} else { } else {
setComparisonData(null); setComparisonData(null);
@@ -431,7 +429,6 @@ export function ComparisonView({
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<ComparisonChart <ComparisonChart
comparisonData={activeComparisonData} comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric} metric={selectedMetric}
metricLabel={metricLabel} metricLabel={metricLabel}
/> />
@@ -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;
} }
+1 -59
View File
@@ -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 && (
+62 -10
View File
@@ -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;
+29 -3
View File
@@ -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}>
+11 -3
View File
@@ -10,13 +10,12 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js'; import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types'; import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css'; import styles from './PerformanceChart.module.css';
@@ -69,7 +68,16 @@ export function PerformanceChart({
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year)); const years = sortedData.map(d => formatAcademicYear(d.year));
const isMobile = useIsMobile(); // ── Mobile detection ─────────────────────────────────────────────────
// Hydration-safe: SSR renders desktop; client flips to mobile after mount.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// ── Build per-year national averages ───────────────────────────────── // ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = sortedData.map(d => { const natRefRwm: (number | null)[] = sortedData.map(d => {
@@ -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;
} }
+51 -2
View File
@@ -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&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; 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}>
@@ -34,15 +34,6 @@
background: #fff; background: #fff;
} }
/* 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 everything else except modals (9999+). */
.wrapper[data-fs-fallback] {
position: fixed;
inset: 0;
z-index: 5000;
}
.skeleton { .skeleton {
width: 100%; width: 100%;
height: 100%; height: 100%;
+4 -29
View File
@@ -29,50 +29,25 @@ interface SchoolHeroMapProps {
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>( export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
function SchoolHeroMap({ lat, lng }, ref) { function SchoolHeroMap({ lat, lng }, ref) {
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;
const open = useCallback(() => { const open = useCallback(() => {
const el = wrapperRef.current; wrapperRef.current?.requestFullscreen?.().catch(() => {});
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, []); }, []);
const close = useCallback(() => { const close = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
setFallbackFullscreen(false);
}, []); }, []);
useImperativeHandle(ref, () => ({ open }), [open]); useImperativeHandle(ref, () => ({ open }), [open]);
useEffect(() => { useEffect(() => {
const onChange = () => setNativeFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange);
}, []); }, []);
// The fallback overlay sits on top of the page rather than replacing it,
// so lock body scroll while it is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
return ( return (
<div <div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
ref={wrapperRef}
className={styles.wrapper}
data-fullscreen={isFullscreen || undefined}
data-fs-fallback={fallbackFullscreen || undefined}
>
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} /> <LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
{isFullscreen ? ( {isFullscreen ? (
@@ -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;
+7 -37
View File
@@ -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}>
-23
View File
@@ -1,23 +0,0 @@
/**
* Viewport hook shared by the chart components.
* Hydration-safe: SSR and the first client render report desktop; the
* media-query subscription flips the value after mount.
*/
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile(maxWidth = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return isMobile;
}
-1
View File
@@ -29,7 +29,6 @@ export type EventName =
| 'compare_viewed' | 'compare_viewed'
| 'compare_metric_changed' | 'compare_metric_changed'
| 'compare_shared' | 'compare_shared'
| 'compare_focus_school'
// Operational // Operational
| 'api_error' | 'api_error'
| 'results_load_more'; | 'results_load_more';
+20
View File
@@ -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. */
-51
View File
@@ -317,57 +317,6 @@ export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
} }
} }
/**
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
*/
export type MetricKind = 'percentage' | 'progress' | 'score';
export function metricKind(metric: string): MetricKind {
if (metric.includes('progress')) return 'progress';
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
return 'score';
}
/**
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
* series remain distinguishable. Padding keeps a minimum span so noise is
* not magnified into drama.
*
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
* data is non-negative (some trend metrics have `pct` in the key but hold
* negative year-over-year deltas).
* - progress: symmetric around 0 so the zero line always shows.
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
* 0 only when the data is non-negative.
*/
export function computeYBounds(
values: Array<number | null | undefined>,
kind: MetricKind,
): { min?: number; max?: number } {
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (nums.length === 0) return {};
const lo = Math.min(...nums);
const hi = Math.max(...nums);
if (kind === 'progress') {
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
return { min: -reach, max: reach };
}
if (kind === 'percentage') {
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
const min = Math.floor((lo - pad) / 5) * 5;
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
return { min: lo >= 0 ? Math.max(0, min) : min, max };
}
// score
const pad = Math.max(2, (hi - lo) * 0.2);
const min = Math.floor(lo - pad);
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
}
// ============================================================================ // ============================================================================
// Local Storage Utilities // Local Storage Utilities
// ============================================================================ // ============================================================================
+15 -4
View File
@@ -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: {
+1
View File
@@ -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
+25
View File
@@ -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(
+5
View File
@@ -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
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Comment-triggered PR fix-ups, powered by Claude Code.
Runs when a maintainer comments `@claude <instruction>` on a pull request.
Checks out the PR head branch, hands the instruction to headless Claude Code
(subscription OAuth auth no API billing), commits and pushes whatever it
changed (which re-runs the PR checks), and replies on the PR with a summary.
Uses only the Python standard library plus the `claude` CLI.
Required environment:
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token`
GITEA_TOKEN run-scoped token (checkout auth handles the push)
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
GITEA_REPOSITORY owner/repo
PR_NUMBER pull request index
COMMENT_BODY the triggering comment text
"""
import json
import os
import subprocess
import sys
import urllib.request
TRIGGER = "@claude"
CLAUDE_TIMEOUT_S = 2400 # 40 min ceiling for one fix-up session
def api(path: str, payload: dict | None = None) -> dict:
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"]
req = urllib.request.Request(
f"{server}/api/v1/repos/{repo}{path}",
data=json.dumps(payload).encode() if payload is not None else None,
headers={
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json",
},
method="POST" if payload is not None else "GET",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def run(*cmd: str, **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
def main() -> int:
pr_number = os.environ["PR_NUMBER"]
instruction = os.environ["COMMENT_BODY"].strip()
if instruction.lower().startswith(TRIGGER):
instruction = instruction[len(TRIGGER):].strip()
if not instruction:
print("Empty instruction after trigger word; nothing to do")
return 0
pr = api(f"/pulls/{pr_number}")
head_ref = pr["head"]["ref"]
base_ref = pr["base"]["ref"]
run("git", "fetch", "origin", head_ref, base_ref)
run("git", "checkout", head_ref)
prompt = f"""You are working on pull request #{pr_number} ("{pr['title']}")
in the SchoolCompare repository. The PR branch is checked out; its base is
{base_ref}. A maintainer left this instruction on the PR:
{instruction}
Implement exactly what was asked, following the conventions in CLAUDE.md.
Run any relevant tests or typechecks you can. Do NOT commit or push the
harness handles that. When done, summarise in a few sentences what you
changed and how you verified it."""
proc = subprocess.run(
["claude", "-p", prompt, "--dangerously-skip-permissions", "--output-format", "json"],
capture_output=True,
text=True,
timeout=CLAUDE_TIMEOUT_S,
)
if proc.returncode != 0:
raise RuntimeError(f"claude CLI failed:\n{proc.stderr[-2000:]}")
summary = json.loads(proc.stdout)["result"].strip()
changed = run("git", "status", "--porcelain").stdout.strip()
if changed:
run("git", "config", "user.name", "Claude (CI)")
run("git", "config", "user.email", "noreply@anthropic.com")
run("git", "add", "-A")
title = instruction.splitlines()[0][:60]
run("git", "commit", "-m", f"ai: {title}\n\nRequested via PR comment; applied by Claude Code in CI.")
run("git", "push", "origin", head_ref)
sha = run("git", "rev-parse", "--short", "HEAD").stdout.strip()
reply = f"## 🤖 Claude fix-up applied (`{sha}`)\n\n{summary}\n\n_PR checks re-run automatically on the new commit._"
else:
reply = f"## 🤖 Claude fix-up — no changes made\n\n{summary}"
api(f"/issues/{pr_number}/comments", {"body": reply})
print(reply)
return 0
if __name__ == "__main__":
sys.exit(main())
-5
View File
@@ -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;