Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a5783da1 | ||
|
|
5c39131b50 | ||
|
|
95081d38bd | ||
|
|
694b6013b3 | ||
|
|
9f8dba227c | ||
|
|
18cd805c6c | ||
|
|
22769b6295 | ||
|
|
90f2a02e75 | ||
|
|
d52d384cf2 | ||
|
|
ff606dad71 | ||
|
|
acec8135e1 | ||
|
|
0a370e3b63 | ||
|
|
6c872ce726 | ||
|
|
23b4e1c453 | ||
|
|
deeef23131 | ||
|
|
4ece55b031 | ||
|
|
515494dbf0 | ||
|
|
5772c54ccd | ||
|
|
c62ba0ca25 | ||
|
|
b5a63e82d4 | ||
|
|
f5de745a8b | ||
|
|
d0895c71df | ||
|
|
df0bf1c4d6 | ||
|
|
4a52735356 | ||
|
|
f2ed49c0a1 | ||
|
|
f24b8044f8 |
@@ -1,12 +1,9 @@
|
||||
name: Build and Push Docker Images
|
||||
name: Deploy (staging -> E2E gate -> production)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: privaterepo.sitaru.org
|
||||
@@ -45,17 +42,15 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=backend-
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
type=sha
|
||||
type=raw,value=staging
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
||||
@@ -91,24 +86,20 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=frontend-
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
type=sha
|
||||
type=raw,value=staging
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./nextjs-app
|
||||
file: ./nextjs-app/Dockerfile
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
build-args: |
|
||||
FASTAPI_URL=http://backend:80/api
|
||||
# Cache disabled due to registry size limits
|
||||
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
|
||||
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
build-pipeline:
|
||||
name: Build Pipeline (Meltano + dbt + Airflow)
|
||||
@@ -140,28 +131,110 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=pipeline-
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
type=sha
|
||||
type=raw,value=staging
|
||||
|
||||
- name: Build and push Pipeline Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./pipeline
|
||||
file: ./pipeline/Dockerfile
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta-pipeline.outputs.tags }}
|
||||
labels: ${{ steps.meta-pipeline.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
trigger-deployment:
|
||||
name: Trigger Portainer Update
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-backend, build-frontend, build-pipeline]
|
||||
if: gitea.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Trigger Portainer stack update
|
||||
- name: Trigger staging stack update
|
||||
run: curl -fsSk -X POST "${{ secrets.PORTAINER_STAGING_WEBHOOK }}"
|
||||
|
||||
- name: Wait for staging to become healthy
|
||||
run: |
|
||||
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"
|
||||
echo "Polling ${STAGING_BASE_URL} for up to 5 minutes..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null --max-time 10 "${STAGING_BASE_URL}/"; then
|
||||
echo "Staging is up (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "Staging did not become healthy in time" >&2
|
||||
exit 1
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
|
||||
e2e-staging:
|
||||
name: E2E Journeys against Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-staging]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Playwright
|
||||
working-directory: e2e
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E journeys
|
||||
working-directory: e2e
|
||||
run: npx playwright test
|
||||
env:
|
||||
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
|
||||
promote-prod:
|
||||
name: Promote to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [e2e-staging]
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Retag verified images as prod
|
||||
run: |
|
||||
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
|
||||
for IMAGE in \
|
||||
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
|
||||
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
|
||||
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
|
||||
# Keep a rollback pointer before moving :prod
|
||||
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
|
||||
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
|
||||
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
|
||||
done
|
||||
|
||||
- name: Trigger production stack update
|
||||
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
|
||||
|
||||
- name: Wait for production to become healthy
|
||||
run: |
|
||||
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
|
||||
echo "Production is up (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "Production did not become healthy in time" >&2
|
||||
exit 1
|
||||
env:
|
||||
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
|
||||
@@ -0,0 +1,188 @@
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: privaterepo.sitaru.org
|
||||
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||
|
||||
jobs:
|
||||
frontend-checks:
|
||||
name: Frontend Typecheck + Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: nextjs-app/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: nextjs-app
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: nextjs-app
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: nextjs-app
|
||||
run: npm test
|
||||
|
||||
backend-checks:
|
||||
name: Backend Smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Import smoke test
|
||||
run: python -c "from backend.app import app; print('backend imports OK')"
|
||||
|
||||
build-backend:
|
||||
name: Build Backend (no push)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["10.0.1.224:6000"]
|
||||
[registry."10.0.1.224:6000"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
||||
|
||||
build-frontend:
|
||||
name: Build Frontend (no push)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["10.0.1.224:6000"]
|
||||
[registry."10.0.1.224:6000"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./nextjs-app
|
||||
file: ./nextjs-app/Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
FASTAPI_URL=http://backend:80/api
|
||||
|
||||
build-pipeline:
|
||||
name: Build Pipeline (no push)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["10.0.1.224:6000"]
|
||||
[registry."10.0.1.224:6000"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build Pipeline Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./pipeline
|
||||
file: ./pipeline/Dockerfile
|
||||
push: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
||||
|
||||
ai-review:
|
||||
name: AI Code Review (Claude)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Review PR diff with Claude Code
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Auto-provided per-run token from Gitea Actions (repo-scoped).
|
||||
# GITHUB_TOKEN is the documented name; GITEA_TOKEN is its alias.
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
BASE_REF: ${{ gitea.event.pull_request.base.ref }}
|
||||
run: python scripts/ci/ai_review.py
|
||||
+5
-3
@@ -572,7 +572,7 @@ async def get_school_details(request: Request, urn: int):
|
||||
# Get latest info for the school
|
||||
latest = school_data.iloc[-1]
|
||||
|
||||
# Fetch supplementary data (Ofsted, Parent View, admissions, etc.)
|
||||
# Fetch supplementary data (Ofsted, admissions, etc.)
|
||||
from .database import SessionLocal
|
||||
supplementary = {}
|
||||
try:
|
||||
@@ -605,7 +605,6 @@ async def get_school_details(request: Request, urn: int):
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
# Supplementary data (null if not yet populated by Kestra)
|
||||
"ofsted": supplementary.get("ofsted"),
|
||||
"parent_view": supplementary.get("parent_view"),
|
||||
"census": supplementary.get("census"),
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
@@ -834,7 +833,10 @@ async def get_rankings(
|
||||
request: Request,
|
||||
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
||||
year: Optional[int] = Query(
|
||||
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
|
||||
None,
|
||||
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"),
|
||||
local_authority: Optional[str] = Query(
|
||||
|
||||
+1
-25
@@ -14,7 +14,7 @@ from .config import settings
|
||||
from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactParentView, FactAdmissions,
|
||||
FactOfstedInspection, FactAdmissions,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
from .schemas import SCHOOL_TYPE_MAP
|
||||
@@ -446,30 +446,6 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
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)
|
||||
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||
result["census"] = (
|
||||
|
||||
@@ -433,6 +433,25 @@ def _apply_schema_alterations():
|
||||
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:
|
||||
"""
|
||||
Run a complete migration: drop all tables and reimport from CSV.
|
||||
@@ -479,6 +498,9 @@ def run_full_migration(geocode: bool = False) -> bool:
|
||||
print("Applying column additions to supplementary tables...")
|
||||
_apply_schema_alterations()
|
||||
|
||||
print("Dropping retired tables...")
|
||||
_apply_schema_drops()
|
||||
|
||||
print("\nLoading CSV data...")
|
||||
df = load_csv_data(settings.data_dir)
|
||||
|
||||
|
||||
@@ -149,29 +149,6 @@ class FactOfstedInspection(Base):
|
||||
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):
|
||||
"""School admissions — one row per URN per year."""
|
||||
__tablename__ = "fact_admissions"
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ WHEN TO BUMP:
|
||||
"""
|
||||
|
||||
# Current schema version - increment when models change
|
||||
SCHEMA_VERSION = 5
|
||||
SCHEMA_VERSION = 6
|
||||
|
||||
# Changelog for documentation
|
||||
SCHEMA_CHANGELOG = {
|
||||
@@ -22,4 +22,5 @@ SCHEMA_CHANGELOG = {
|
||||
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)",
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -105,8 +105,22 @@ This starts:
|
||||
- `GET /api/metrics` - Metric definitions (single source of truth)
|
||||
- `GET /api/data-info` - Database stats
|
||||
|
||||
## SDLC
|
||||
|
||||
Full details in `docs/DEPLOY.md`. The short version:
|
||||
|
||||
- **Never push to `main` directly.** Work on a feature branch and open a PR;
|
||||
branch protection requires the PR checks (typecheck, tests, builds, AI review)
|
||||
to pass before merge.
|
||||
- Merging to `main` deploys automatically: images are built once, deployed to
|
||||
the **staging** Portainer stack, verified by the Playwright journeys in
|
||||
`e2e/`, and only then retagged `:prod` and rolled out to production.
|
||||
- If you change user-facing behaviour, update or extend the `e2e/` journey
|
||||
tests in the same PR — they are the promotion gate.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
- Added staging environment + automated staging→prod pipeline (Gitea Actions)
|
||||
- Migrated from CSV file storage to PostgreSQL database
|
||||
- Added location-based search using postcode geocoding
|
||||
- Added local authority filter to rankings
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Portainer Stack Definition for School Compare — STAGING
|
||||
#
|
||||
# Deploy this as a *separate* Portainer stack (e.g. "schoolcompare-staging")
|
||||
# alongside the production stack. Differences from production:
|
||||
# - images pinned to :staging (pushed by every merge to main, before the E2E gate)
|
||||
# - sc_staging_* container names
|
||||
# - own macvlan IPs (STAGING_DB_IP / STAGING_FRONTEND_IP env vars)
|
||||
# - Airflow UI published on 8081 (prod uses 8080)
|
||||
# - volumes are isolated automatically: Portainer prefixes volume names with
|
||||
# the stack name, so this stack gets its own postgres/typesense/airflow data
|
||||
#
|
||||
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
|
||||
# DB_USERNAME — PostgreSQL username
|
||||
# DB_PASSWORD — PostgreSQL password
|
||||
# DB_DATABASE_NAME — PostgreSQL database name
|
||||
# ADMIN_API_KEY — Backend admin API key
|
||||
# TYPESENSE_API_KEY — Typesense admin API key
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
|
||||
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
|
||||
|
||||
services:
|
||||
|
||||
# ── PostgreSQL ────────────────────────────────────────────────────────
|
||||
sc_database:
|
||||
container_name: sc_staging_postgres
|
||||
image: postgis/postgis:18-3.6-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
shm_size: 128mb
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
ipv4_address: ${STAGING_DB_IP:-10.0.1.190}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# ── FastAPI Backend ───────────────────────────────────────────────────
|
||||
backend:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-backend:staging
|
||||
container_name: sc_staging_backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
PYTHONUNBUFFERED: 1
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/api/data-info"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Next.js Frontend ──────────────────────────────────────────────────
|
||||
frontend:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-frontend:staging
|
||||
container_name: sc_staging_nextjs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
- FASTAPI_URL=http://backend:80/api
|
||||
- TYPESENSE_URL=http://typesense:8108
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
ipv4_address: ${STAGING_FRONTEND_IP:-10.0.1.151}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ── Typesense Search Engine ───────────────────────────────────────────
|
||||
typesense:
|
||||
image: typesense/typesense:30.1
|
||||
container_name: sc_staging_typesense
|
||||
environment:
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
TYPESENSE_DATA_DIR: /data
|
||||
volumes:
|
||||
- typesense_data:/data
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "cat < /dev/tcp/localhost/8108"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Airflow API Server + UI (staging: http://<host>:8081) ─────────────
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||
container_name: sc_staging_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
PG_USER: ${DB_USERNAME}
|
||||
PG_PASSWORD: ${DB_PASSWORD}
|
||||
PG_DATABASE: ${DB_DATABASE_NAME}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
volumes:
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v2/monitor/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── Airflow Scheduler ──────────────────────────────────────────────
|
||||
airflow-scheduler:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||
container_name: sc_staging_airflow_scheduler
|
||||
command: airflow scheduler
|
||||
environment:
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
PG_USER: ${DB_USERNAME}
|
||||
PG_PASSWORD: ${DB_PASSWORD}
|
||||
PG_DATABASE: ${DB_DATABASE_NAME}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
volumes:
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
|
||||
airflow-init:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||
container_name: sc_staging_airflow_init
|
||||
command: bash -c "airflow db migrate && airflow dags reserialize"
|
||||
environment:
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: "no"
|
||||
|
||||
networks:
|
||||
backend:
|
||||
driver: bridge
|
||||
macvlan:
|
||||
external:
|
||||
name: macvlan
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
|
||||
# ── FastAPI Backend ───────────────────────────────────────────────────
|
||||
backend:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-backend:latest
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-backend:prod
|
||||
container_name: schoolcompare_backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
|
||||
# ── Next.js Frontend ──────────────────────────────────────────────────
|
||||
frontend:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-frontend:latest
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-frontend:prod
|
||||
container_name: schoolcompare_nextjs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
@@ -103,7 +103,7 @@ services:
|
||||
|
||||
# ── Airflow API Server + UI ───────────────────────────────────────────
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
|
||||
container_name: schoolcompare_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
ports:
|
||||
@@ -142,7 +142,7 @@ services:
|
||||
|
||||
# ── Airflow Scheduler ──────────────────────────────────────────────
|
||||
airflow-scheduler:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
|
||||
container_name: schoolcompare_airflow_scheduler
|
||||
command: airflow scheduler
|
||||
environment:
|
||||
@@ -172,7 +172,7 @@ services:
|
||||
|
||||
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
|
||||
airflow-init:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
|
||||
container_name: schoolcompare_airflow_init
|
||||
command: bash -c "airflow db migrate && airflow dags delete school_data_daily -y 2>/dev/null; airflow dags delete school_data_monthly_ofsted -y 2>/dev/null; airflow dags delete school_data_annual_ees -y 2>/dev/null; airflow dags reserialize"
|
||||
environment:
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# SDLC & Deployment Pipeline
|
||||
|
||||
SchoolCompare uses a fully automated staging → production pipeline on Gitea
|
||||
Actions. AI writes the code on feature branches; the pipeline verifies every
|
||||
change on a staging environment before promoting the exact same images to
|
||||
production. Human input is directional only: feature requests, PR review if
|
||||
desired, and intervention when a gate fails.
|
||||
|
||||
## The flow
|
||||
|
||||
```
|
||||
feature branch (AI-authored)
|
||||
│ PR to main
|
||||
▼
|
||||
PR checks (.gitea/workflows/pr-checks.yml)
|
||||
typecheck + unit tests + backend smoke + image builds (no push)
|
||||
+ Claude code review posted as a PR comment (severe findings fail the check)
|
||||
│ merge (branch protection requires green checks)
|
||||
▼
|
||||
Deploy pipeline (.gitea/workflows/deploy.yml)
|
||||
1. build & push images → tags sha-<sha>, staging
|
||||
2. staging Portainer webhook → wait for staging health
|
||||
3. Playwright E2E journeys against staging
|
||||
4. retag sha-<sha> → :prod (same bytes — build once, promote the image)
|
||||
previous :prod saved as :prod-previous
|
||||
5. prod Portainer webhook → wait for prod health
|
||||
```
|
||||
|
||||
Key principle: **build once, promote the exact image**. Production pins `:prod`,
|
||||
which only moves after the E2E gate passes on staging. Nothing tags `:latest`
|
||||
anymore.
|
||||
|
||||
## Branch & PR workflow
|
||||
|
||||
- `main` is protected: no direct pushes, PRs require green status checks.
|
||||
- All work (human or AI) happens on feature branches → PR to `main`.
|
||||
- Merging to `main` **is** the release action. If staging or the E2E gate
|
||||
fails, production is untouched.
|
||||
|
||||
## Environments
|
||||
|
||||
| | Production | Staging |
|
||||
|---|---|---|
|
||||
| Portainer stack file | `docker-compose.portainer.yml` | `docker-compose.portainer.staging.yml` |
|
||||
| Image tag | `:prod` | `:staging` |
|
||||
| Container prefix | `sc_` / `schoolcompare_` | `sc_staging_` |
|
||||
| Frontend macvlan IP | 10.0.1.150 | `STAGING_FRONTEND_IP` (default 10.0.1.151) |
|
||||
| Postgres macvlan IP | 10.0.1.189 | `STAGING_DB_IP` (default 10.0.1.190) |
|
||||
| Airflow UI port | 8080 | 8081 |
|
||||
| Volumes | stack-prefixed | stack-prefixed (fully isolated) |
|
||||
|
||||
Staging gets `:staging` images on every merge to main — even ones that later
|
||||
fail the E2E gate. That's the point: staging absorbs the risk.
|
||||
|
||||
## Gitea repository secrets
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (already set) |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription auth for the PR review — generate with `claude setup-token` on your machine |
|
||||
| `PORTAINER_STAGING_WEBHOOK` | staging stack redeploy webhook URL |
|
||||
| `PORTAINER_PROD_WEBHOOK` | production stack redeploy webhook URL |
|
||||
| `STAGING_BASE_URL` | e.g. `http://10.0.1.151:3000` — health poll + E2E target |
|
||||
| `PROD_BASE_URL` | e.g. `http://10.0.1.150:3000` — post-promotion health poll |
|
||||
|
||||
## One-time setup checklist
|
||||
|
||||
1. **Create the staging stack** in Portainer from
|
||||
`docker-compose.portainer.staging.yml` (stack name e.g.
|
||||
`schoolcompare-staging`). Set the same environment variables as prod plus
|
||||
`STAGING_DB_IP` / `STAGING_FRONTEND_IP` if the defaults clash.
|
||||
2. **Enable webhooks** on both stacks (Portainer → Stack → Webhook) and store
|
||||
the URLs as `PORTAINER_STAGING_WEBHOOK` / `PORTAINER_PROD_WEBHOOK`. Remove
|
||||
the old hardcoded webhook usage (now gone from the workflows).
|
||||
3. **Add the remaining secrets** listed above in Gitea → repo → Settings →
|
||||
Actions → Secrets.
|
||||
4. **Protect `main`** in Gitea → Settings → Branches: require PRs, require the
|
||||
pr-checks status checks (frontend, backend, builds, ai-review) to pass.
|
||||
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
||||
itself from source, exercising the pipeline image end-to-end):
|
||||
- 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_annual_ees` and `school_data_annual_idaci`.
|
||||
- First runs download from government sources (GIAS, Ofsted, EES, IDACI),
|
||||
run dbt, and sync Typesense — expect the initial backfill to take a while.
|
||||
- The scheduled DAGs then keep staging fresh exactly like prod.
|
||||
6. **Switch the prod stack to `:prod` tags** — the repo's
|
||||
`docker-compose.portainer.yml` is already updated; redeploy the prod stack
|
||||
from it. Until the first pipeline run promotes an image, tag the current
|
||||
images manually: `docker buildx imagetools create -t <image>:prod <image>:latest`
|
||||
for each of the three images.
|
||||
|
||||
## Rollback
|
||||
|
||||
Every promotion first re-points `:prod-previous` at the outgoing `:prod`.
|
||||
To roll back:
|
||||
|
||||
```bash
|
||||
for img in backend frontend pipeline; do
|
||||
docker buildx imagetools create \
|
||||
-t privaterepo.sitaru.org/tudor/school_compare-$img:prod \
|
||||
privaterepo.sitaru.org/tudor/school_compare-$img:prod-previous
|
||||
done
|
||||
curl -fsSk -X POST "$PORTAINER_PROD_WEBHOOK"
|
||||
```
|
||||
|
||||
Or promote any older build directly: `imagetools create -t <image>:prod <image>:sha-<shortsha>`.
|
||||
|
||||
## E2E suite
|
||||
|
||||
Lives in `e2e/` (own package — CI installs it without the app's node_modules).
|
||||
Journeys: home + name search, postcode search, school detail, two-school
|
||||
comparison, rankings table. Run locally against any environment:
|
||||
|
||||
```bash
|
||||
cd e2e && npm ci
|
||||
BASE_URL=http://10.0.1.151:3000 npx playwright test
|
||||
```
|
||||
|
||||
Tests assert data invariants (results exist, charts render), not exact
|
||||
numbers, so scheduled data refreshes don't break the gate.
|
||||
|
||||
## AI code review
|
||||
|
||||
`scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
|
||||
(`claude -p`, authenticated with the subscription OAuth token — no API
|
||||
billing), posts the structured findings as a PR comment using the per-run
|
||||
token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
|
||||
needed), and fails the check only when a finding is rated
|
||||
**severe** (would break prod, leak data, or corrupt data). Minor findings are
|
||||
informational and never block a merge.
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "schoolcompare-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "schoolcompare-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "schoolcompare-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Journey tests run against staging as the production promotion gate",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
workers: 2,
|
||||
reporter: process.env.CI ? 'list' : 'html',
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Journey tests for SchoolCompare, run against the staging environment as the
|
||||
* gate before promotion to production. They assert stable data invariants
|
||||
* (results exist, key UI renders) rather than exact numbers, so routine data
|
||||
* refreshes don't break the pipeline.
|
||||
*/
|
||||
|
||||
async function searchByName(page: Page, query: string) {
|
||||
await page.goto('/');
|
||||
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||
await searchInput.fill(query);
|
||||
await searchInput.press('Enter');
|
||||
await page.waitForURL(/search=|postcode=/);
|
||||
}
|
||||
|
||||
function schoolLinks(page: Page) {
|
||||
return page.locator('a[href^="/school/"]');
|
||||
}
|
||||
|
||||
test('home page loads with hero search', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('searching by name returns school results', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
expect(await schoolLinks(page).count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('searching by postcode returns nearby schools', async ({ page }) => {
|
||||
await searchByName(page, 'B1 1BB');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('school detail page renders name and performance data', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
const firstSchool = schoolLinks(page).first();
|
||||
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
|
||||
await firstSchool.click();
|
||||
await page.waitForURL(/\/school\//);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
// The detail page renders at least one *visible* chart canvas. Plain
|
||||
// .first() is wrong here: the admissions card stacks its year/trend views
|
||||
// in one grid cell and keeps the inactive view's canvas visibility:hidden
|
||||
// by design, and that canvas comes first in the DOM.
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
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('comparing two schools shows both side by side', async ({ page }) => {
|
||||
// Collect two school URNs from search results, then load the share URL
|
||||
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))];
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||
// Both schools' detail links should render in the comparison view
|
||||
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
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))];
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The mobile chart legend renders one chip per school inside the chart card.
|
||||
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||
const chips = chipGroup.getByRole('button');
|
||||
await expect(chips).toHaveCount(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 }) => {
|
||||
await page.goto('/rankings');
|
||||
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
||||
const rows = page.locator('table tbody tr');
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
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);
|
||||
});
|
||||
@@ -22,7 +22,9 @@ COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build argument for FastAPI URL (used by Next.js rewrites at build time)
|
||||
# Default backend URL for any server-side fetch during `next build`. The
|
||||
# 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
|
||||
ENV FASTAPI_URL=${FASTAPI_URL}
|
||||
|
||||
|
||||
@@ -34,24 +34,24 @@ describe('SchoolCard', () => {
|
||||
render(<SchoolCard school={mockSchool} />);
|
||||
|
||||
const link = screen.getByRole('link', { name: /test primary school/i });
|
||||
expect(link).toHaveAttribute('href', '/school/100001');
|
||||
expect(link).toHaveAttribute('href', '/school/100001-test-primary-school');
|
||||
});
|
||||
|
||||
it('calls onAddToCompare when Add to Compare button is clicked', () => {
|
||||
it('calls onAddToCompare when the Compare button is clicked', () => {
|
||||
const mockAddToCompare = jest.fn();
|
||||
render(<SchoolCard school={mockSchool} onAddToCompare={mockAddToCompare} />);
|
||||
|
||||
const addButton = screen.getByText('Add to Compare');
|
||||
const addButton = screen.getByText('+ Compare');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockAddToCompare).toHaveBeenCalledWith(mockSchool);
|
||||
expect(mockAddToCompare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not render Add to Compare button when handler not provided', () => {
|
||||
it('does not render the Compare button when handler not provided', () => {
|
||||
render(<SchoolCard school={mockSchool} />);
|
||||
|
||||
expect(screen.queryByText('Add to Compare')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('+ Compare')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays trend indicator for positive change', () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
isValidPostcode,
|
||||
debounce,
|
||||
buildOfstedListBadge,
|
||||
metricKind,
|
||||
computeYBounds,
|
||||
} from '@/lib/utils';
|
||||
|
||||
describe('formatPercentage', () => {
|
||||
@@ -19,7 +21,7 @@ describe('formatPercentage', () => {
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
expect(formatPercentage(null)).toBe('-');
|
||||
expect(formatPercentage(null)).toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +33,7 @@ describe('formatProgress', () => {
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
expect(formatProgress(null)).toBe('-');
|
||||
expect(formatProgress(null)).toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,16 +46,16 @@ describe('calculateTrend', () => {
|
||||
expect(calculateTrend(70, 75)).toBe('down');
|
||||
});
|
||||
|
||||
it('calculates same trend', () => {
|
||||
expect(calculateTrend(75, 75)).toBe('same');
|
||||
it('calculates stable trend', () => {
|
||||
expect(calculateTrend(75, 75)).toBe('stable');
|
||||
});
|
||||
|
||||
it('handles null previous value', () => {
|
||||
expect(calculateTrend(75, null)).toBe('same');
|
||||
expect(calculateTrend(75, null)).toBe('stable');
|
||||
});
|
||||
|
||||
it('handles null current value', () => {
|
||||
expect(calculateTrend(null, 75)).toBe('same');
|
||||
expect(calculateTrend(null, 75)).toBe('stable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +74,13 @@ describe('isValidPostcode', () => {
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('delays function execution', () => {
|
||||
const mockFn = jest.fn();
|
||||
@@ -100,8 +108,6 @@ describe('debounce', () => {
|
||||
expect(mockFn).toHaveBeenCalledWith('third');
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('buildOfstedListBadge', () => {
|
||||
@@ -155,3 +161,54 @@ describe('buildOfstedListBadge', () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -76,9 +76,12 @@ export default function RootLayout({
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
{/* data-domains: the tracker only fires on the production hostnames,
|
||||
so staging (same image, different host) never pollutes Umami */}
|
||||
<Script
|
||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||
data-performance="true"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
|
||||
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -189,7 +189,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
parentView={parent_view ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
@@ -203,7 +202,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
parentView={parent_view ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* 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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
max-width: 100%;
|
||||
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;
|
||||
max-width: 9rem;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,82 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
||||
import {
|
||||
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 {
|
||||
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;
|
||||
metricLabel: string;
|
||||
}
|
||||
|
||||
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
|
||||
// Get all schools and their data
|
||||
const schools = Object.entries(comparisonData);
|
||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||
// converging lines stay tellable apart without relying on hue alone.
|
||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: 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) {
|
||||
return <div>No data available</div>;
|
||||
}
|
||||
|
||||
// Get years from first school (assuming all schools have same years)
|
||||
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
|
||||
// Union of years across all schools — coverage differs between them.
|
||||
const years = [
|
||||
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
// Create datasets for each school
|
||||
const datasets = schools.map(([urn, data], index) => {
|
||||
const schoolInfo = data.school_info;
|
||||
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
|
||||
const data = comparisonData[String(school.urn)];
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
label: schoolInfo.school_name,
|
||||
label: school.school_name,
|
||||
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;
|
||||
return yearData[metric as keyof typeof yearData] as number | null;
|
||||
}),
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
||||
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||||
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 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,
|
||||
spanGaps: true,
|
||||
};
|
||||
@@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
datasets,
|
||||
};
|
||||
|
||||
// Determine if metric is a progress score or percentage
|
||||
const isProgressScore = metric.includes('progress');
|
||||
const isPercentage = metric.includes('pct') || metric.includes('rate');
|
||||
const kind = metricKind(metric);
|
||||
const yBounds = computeYBounds(
|
||||
datasets.flatMap((ds) => ds.data as Array<number | null>),
|
||||
kind,
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
@@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: !isMobile,
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
@@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
},
|
||||
},
|
||||
},
|
||||
// No in-chart title: the section heading and metric selector above the
|
||||
// chart already state the metric.
|
||||
title: {
|
||||
display: true,
|
||||
text: `${metricLabel} - Comparison`,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold',
|
||||
},
|
||||
padding: {
|
||||
bottom: 20,
|
||||
},
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
padding: isMobile ? 10 : 12,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
size: isMobile ? 12 : 14,
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
size: isMobile ? 11 : 13,
|
||||
},
|
||||
usePointStyle: true,
|
||||
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
@@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
if (isProgressScore) {
|
||||
label += context.parsed.y.toFixed(1);
|
||||
} else if (isPercentage) {
|
||||
label += context.parsed.y.toFixed(1) + '%';
|
||||
} else {
|
||||
label += context.parsed.y.toFixed(1);
|
||||
}
|
||||
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
|
||||
} else {
|
||||
label += 'N/A';
|
||||
}
|
||||
@@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
|
||||
display: !isMobile,
|
||||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
},
|
||||
...(isPercentage && {
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
...yBounds,
|
||||
ticks: {
|
||||
font: { size: isMobile ? 10 : 12 },
|
||||
...(isMobile && { maxTicksLimit: 5 }),
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
@@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
display: !isMobile,
|
||||
text: 'Year',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
font: { size: isMobile ? 10 : 12 },
|
||||
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={options} />;
|
||||
const toggleFocus = (urn: number) => {
|
||||
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,7 +454,10 @@
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
height: 300px;
|
||||
/* Taller than desktop's proportion would suggest: the chip legend row
|
||||
sits inside, and the in-chart title/legend/axis titles are gone, so
|
||||
nearly all of this is plot area. */
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
|
||||
@@ -111,8 +111,10 @@ export function ComparisonView({
|
||||
setComparisonData(data.comparison);
|
||||
})
|
||||
.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);
|
||||
setComparisonData(null);
|
||||
});
|
||||
} else {
|
||||
setComparisonData(null);
|
||||
@@ -429,6 +431,7 @@ export function ComparisonView({
|
||||
<div className={styles.chartContainer}>
|
||||
<ComparisonChart
|
||||
comparisonData={activeComparisonData}
|
||||
schools={activeSchools}
|
||||
metric={selectedMetric}
|
||||
metricLabel={metricLabel}
|
||||
/>
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
|
||||
.heroDescriptionCompact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.heroSection {
|
||||
padding-top: 1.5rem;
|
||||
@@ -74,18 +79,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||
long descriptive paragraph — the h1 already names the product, and the
|
||||
search input is the primary action users came to perform. */
|
||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and swap
|
||||
the long descriptive paragraph for one compact line that carries coverage
|
||||
("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
|
||||
visitor otherwise sees only a poetic h1 and a bare box, with no reason to
|
||||
trust the data (audit P1.7, feeds the 46% home exit rate). */
|
||||
@media (max-width: 640px) {
|
||||
.heroSection {
|
||||
padding-top: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.heroEyebrow,
|
||||
.heroDescription {
|
||||
.heroDescriptionFull {
|
||||
display: none;
|
||||
}
|
||||
.heroDescriptionCompact {
|
||||
display: block;
|
||||
}
|
||||
.heroDescription {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
max-width: 320px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
@@ -267,7 +267,15 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import type { SchoolResult } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './PerformanceChart.module.css';
|
||||
|
||||
@@ -68,16 +69,7 @@ export function PerformanceChart({
|
||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
||||
|
||||
// ── 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);
|
||||
}, []);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// ── Build per-year national averages ─────────────────────────────────
|
||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
||||
|
||||
@@ -187,6 +187,18 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Table auto-layout sizes columns by the unwrapped text, so a long metric
|
||||
label ("Reading, Writing & Maths Combined Higher %") widened the table
|
||||
past the viewport. A block inner span is what actually caps the measured
|
||||
width and forces the label onto multiple lines. */
|
||||
.valueHeaderText {
|
||||
display: block;
|
||||
max-width: 110px;
|
||||
margin: 0 auto;
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.actionHeader {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
@@ -411,7 +423,7 @@
|
||||
}
|
||||
|
||||
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
|
||||
force the whole column wide; let them wrap onto 2 short lines with a
|
||||
force the whole column wide; let them wrap onto short lines with a
|
||||
tighter font so the value cell can stay compact. */
|
||||
.valueHeader {
|
||||
font-size: 0.625rem;
|
||||
@@ -420,6 +432,10 @@
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.valueHeaderText {
|
||||
max-width: 84px;
|
||||
}
|
||||
|
||||
.rankHeader {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@@ -233,7 +233,11 @@ export function RankingsView({
|
||||
<th className={styles.schoolHeader}>School</th>
|
||||
<th className={styles.areaHeader}>Area</th>
|
||||
<th className={styles.typeHeader}>Type</th>
|
||||
<th className={styles.valueHeader}>{metricLabel}</th>
|
||||
<th className={styles.valueHeader}>
|
||||
{/* Inner block caps the column's measured width so long
|
||||
labels wrap instead of widening the table off-screen. */}
|
||||
<span className={styles.valueHeaderText}>{metricLabel}</span>
|
||||
</th>
|
||||
<th className={styles.actionHeader}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -713,18 +713,6 @@
|
||||
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 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -732,18 +720,6 @@
|
||||
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 */
|
||||
.metricsGrid {
|
||||
display: grid;
|
||||
@@ -1094,49 +1070,6 @@
|
||||
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 */
|
||||
.admissionsBadge {
|
||||
display: inline-flex;
|
||||
@@ -1269,25 +1202,6 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
@@ -63,7 +63,6 @@ interface SchoolDetailViewProps {
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
parentView: OfstedParentView | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
@@ -75,7 +74,7 @@ interface SchoolDetailViewProps {
|
||||
|
||||
export function SchoolDetailView({
|
||||
schoolInfo, yearlyData, absenceData,
|
||||
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
||||
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
||||
}: SchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
@@ -234,8 +233,6 @@ export function SchoolDetailView({
|
||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||
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 (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
@@ -549,11 +546,6 @@ export function SchoolDetailView({
|
||||
) : null;
|
||||
})}
|
||||
</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 ── */
|
||||
@@ -572,11 +564,6 @@ export function SchoolDetailView({
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
||||
</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 ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
@@ -1129,42 +1116,6 @@ export function SchoolDetailView({
|
||||
</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 */}
|
||||
{hasSchoolLife && (
|
||||
<section id="school-life" className={styles.card}>
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -29,25 +29,50 @@ interface SchoolHeroMapProps {
|
||||
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
|
||||
function SchoolHeroMap({ lat, lng }, ref) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [nativeFullscreen, setNativeFullscreen] = 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(() => {
|
||||
wrapperRef.current?.requestFullscreen?.().catch(() => {});
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
||||
} else {
|
||||
setFallbackFullscreen(true);
|
||||
}
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
setFallbackFullscreen(false);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ open }), [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
const onChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('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 (
|
||||
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.wrapper}
|
||||
data-fullscreen={isFullscreen || undefined}
|
||||
data-fs-fallback={fallbackFullscreen || undefined}
|
||||
>
|
||||
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
|
||||
|
||||
{isFullscreen ? (
|
||||
|
||||
@@ -383,17 +383,6 @@
|
||||
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 ───────────────────── */
|
||||
.p8Banner {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
@@ -664,60 +653,6 @@
|
||||
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 ──────────────────────────────────────── */
|
||||
.admissionsTypeBadge {
|
||||
border-radius: 6px;
|
||||
@@ -1135,10 +1070,6 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.parentViewLabel {
|
||||
flex-basis: 10rem;
|
||||
}
|
||||
|
||||
.ofstedReportLink {
|
||||
margin-left: 0;
|
||||
display: block;
|
||||
@@ -1151,25 +1082,6 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -19,7 +19,7 @@ const PerformanceChart = dynamic(
|
||||
);
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
@@ -65,7 +65,6 @@ interface SecondarySchoolDetailViewProps {
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
parentView: OfstedParentView | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
senDetail: SenDetail | null;
|
||||
@@ -76,7 +75,7 @@ interface SecondarySchoolDetailViewProps {
|
||||
|
||||
export function SecondarySchoolDetailView({
|
||||
schoolInfo, yearlyData,
|
||||
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
@@ -101,7 +100,6 @@ export function SecondarySchoolDetailView({
|
||||
|
||||
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||||
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 hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||||
@@ -159,7 +157,6 @@ export function SecondarySchoolDetailView({
|
||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
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 (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
|
||||
@@ -435,11 +432,6 @@ export function SecondarySchoolDetailView({
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -775,42 +767,6 @@ export function SecondarySchoolDetailView({
|
||||
</details>
|
||||
</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 ──────────────────────────────────── */}
|
||||
{hasWellbeing && (
|
||||
<section id="wellbeing" className={styles.card}>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type EventName =
|
||||
| 'compare_viewed'
|
||||
| 'compare_metric_changed'
|
||||
| 'compare_shared'
|
||||
| 'compare_focus_school'
|
||||
// Operational
|
||||
| 'api_error'
|
||||
| 'results_load_more';
|
||||
|
||||
@@ -99,25 +99,6 @@ export interface OfstedInspection {
|
||||
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 {
|
||||
year: number;
|
||||
total_pupils: number | null;
|
||||
@@ -312,7 +293,6 @@ export interface SchoolDetailsResponse {
|
||||
absence_data: AbsenceData | null;
|
||||
// Supplementary data (null until Kestra populates)
|
||||
ofsted: OfstedInspection | null;
|
||||
parent_view: OfstedParentView | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||
|
||||
@@ -317,6 +317,57 @@ 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
|
||||
// ============================================================================
|
||||
|
||||
@@ -3,21 +3,10 @@ const nextConfig = {
|
||||
// Enable standalone output for Docker
|
||||
output: 'standalone',
|
||||
|
||||
// API Proxy to FastAPI backend
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api';
|
||||
const backendUrl = apiUrl.replace(/\/api$/, '');
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/sitemap.xml',
|
||||
destination: `${backendUrl}/sitemap.xml`,
|
||||
},
|
||||
];
|
||||
},
|
||||
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
|
||||
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
|
||||
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
|
||||
// instead of being baked into the build.
|
||||
|
||||
// Image optimization
|
||||
images: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
|
||||
@@ -19,7 +19,6 @@ RUN pip install --no-cache-dir \
|
||||
./plugins/extractors/tap-uk-gias \
|
||||
./plugins/extractors/tap-uk-ees \
|
||||
./plugins/extractors/tap-uk-ofsted \
|
||||
./plugins/extractors/tap-uk-parent-view \
|
||||
./plugins/extractors/tap-uk-fbit \
|
||||
./plugins/extractors/tap-uk-idaci
|
||||
|
||||
|
||||
@@ -156,31 +156,6 @@ with DAG(
|
||||
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) ────────────────────────────────────
|
||||
|
||||
with DAG(
|
||||
|
||||
@@ -50,11 +50,6 @@ plugins:
|
||||
kind: string
|
||||
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
|
||||
namespace: uk_fbit
|
||||
pip_url: ./plugins/extractors/tap-uk-fbit
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[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"
|
||||
@@ -1 +0,0 @@
|
||||
"""tap-uk-parent-view: Singer tap for Ofsted Parent View survey data."""
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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,12 +105,6 @@ models:
|
||||
- name: year
|
||||
tests: [not_null]
|
||||
|
||||
- name: fact_parent_view
|
||||
description: Parent View survey responses
|
||||
columns:
|
||||
- name: urn
|
||||
tests: [not_null]
|
||||
|
||||
- name: fact_ks2_national_averages
|
||||
description: Official DfE KS2 national headline averages — one row per academic year
|
||||
columns:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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,9 +53,6 @@ sources:
|
||||
|
||||
# Phonics: no school-level data on EES (only national/LA level)
|
||||
|
||||
- name: parent_view
|
||||
description: Ofsted Parent View survey responses
|
||||
|
||||
- name: fbit_finance
|
||||
description: Financial benchmarking data from FBIT API
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
-- 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
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AI code review for Gitea pull requests, powered by Claude Code.
|
||||
|
||||
Reads the PR diff (base branch vs HEAD), asks Claude Code (headless `claude -p`)
|
||||
to review it, posts the findings as a PR comment via the Gitea API, and exits
|
||||
non-zero only when the review contains at least one severe finding — so the
|
||||
job can gate merges without blocking on nitpicks.
|
||||
|
||||
Uses only the Python standard library; the review itself runs through the
|
||||
Claude Code CLI, authenticated with a subscription OAuth token.
|
||||
|
||||
Required environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token` (subscription auth)
|
||||
GITEA_TOKEN Gitea access token for posting PR comments
|
||||
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
|
||||
GITEA_REPOSITORY owner/repo
|
||||
PR_NUMBER pull request index
|
||||
BASE_REF base branch name (e.g. main)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
MAX_DIFF_CHARS = 150_000
|
||||
|
||||
PROMPT = """You are reviewing a pull request for SchoolCompare, a UK school
|
||||
comparison site (FastAPI backend, Next.js frontend, Airflow/dbt data pipeline,
|
||||
deployed via Gitea Actions to a staging-then-production Docker setup).
|
||||
|
||||
The PR diff is provided on stdin.
|
||||
|
||||
Report correctness bugs, security issues, data-loss risks, and broken deploy/CI
|
||||
configuration. Mark a finding "severe" only if it would break production, leak
|
||||
data, or corrupt data — severe findings block the merge. Everything else
|
||||
(style, performance suggestions, minor cleanups) is "minor". Do not invent
|
||||
findings: an empty findings list is a perfectly good review of a clean diff.
|
||||
|
||||
Respond with ONLY a JSON object (no markdown fences, no prose) of this shape:
|
||||
{
|
||||
"summary": "two or three sentences on what the change does and its health",
|
||||
"findings": [
|
||||
{"severity": "severe" | "minor", "file": "path", "issue": "description"}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
def get_diff(base_ref: str) -> str:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", base_ref],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
diff = subprocess.run(
|
||||
["git", "diff", f"origin/{base_ref}...HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
if len(diff) > MAX_DIFF_CHARS:
|
||||
diff = diff[:MAX_DIFF_CHARS] + "\n\n[diff truncated for review]"
|
||||
return diff
|
||||
|
||||
|
||||
def review(diff: str) -> dict:
|
||||
proc = subprocess.run(
|
||||
["claude", "-p", PROMPT, "--output-format", "json"],
|
||||
input=diff,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"claude CLI failed:\n{proc.stderr}")
|
||||
envelope = json.loads(proc.stdout)
|
||||
result = envelope["result"].strip()
|
||||
# Defensive: strip markdown fences if the model added them anyway
|
||||
if result.startswith("```"):
|
||||
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
|
||||
return json.loads(result)
|
||||
|
||||
|
||||
def format_comment(result: dict) -> str:
|
||||
lines = ["## 🤖 AI Code Review (Claude Code)", "", result["summary"], ""]
|
||||
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||||
minor = [f for f in result["findings"] if f["severity"] == "minor"]
|
||||
if severe:
|
||||
lines.append("### 🔴 Severe (blocks merge)")
|
||||
lines += [f"- **{f['file']}**: {f['issue']}" for f in severe]
|
||||
lines.append("")
|
||||
if minor:
|
||||
lines.append("### 🟡 Minor")
|
||||
lines += [f"- **{f['file']}**: {f['issue']}" for f in minor]
|
||||
lines.append("")
|
||||
if not result["findings"]:
|
||||
lines.append("✅ No issues found.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def post_comment(body: str) -> None:
|
||||
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||
repo = os.environ["GITEA_REPOSITORY"]
|
||||
pr = os.environ["PR_NUMBER"]
|
||||
req = urllib.request.Request(
|
||||
f"{server}/api/v1/repos/{repo}/issues/{pr}/comments",
|
||||
data=json.dumps({"body": body}).encode(),
|
||||
headers={
|
||||
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
if resp.status >= 300:
|
||||
raise RuntimeError(f"Comment post failed: HTTP {resp.status}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
diff = get_diff(os.environ["BASE_REF"])
|
||||
if not diff.strip():
|
||||
print("Empty diff, nothing to review")
|
||||
return 0
|
||||
result = review(diff)
|
||||
comment = format_comment(result)
|
||||
print(comment)
|
||||
post_comment(comment)
|
||||
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||||
if severe:
|
||||
print(f"\n{len(severe)} severe finding(s) — failing the check", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Retire the Ofsted Parent View feature (schema v6).
|
||||
-- The marts schema is dbt-owned; deleting the dbt model stops the table being
|
||||
-- rebuilt but does not drop the existing relation, so apply this directly
|
||||
-- against the staging and production marts databases.
|
||||
DROP TABLE IF EXISTS marts.fact_parent_view CASCADE;
|
||||
Reference in New Issue
Block a user