Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e8df006d7 | ||
|
|
1f8284adfc | ||
|
|
b2dc4d0779 | ||
|
|
1cdcd85e41 | ||
|
|
a00cbe9161 | ||
|
|
64121592fd | ||
|
|
6828f6cd44 | ||
|
|
331ae8d89f | ||
|
|
3adea73ee0 | ||
|
|
47335fcda0 | ||
|
|
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 | ||
|
|
29f79fe948 | ||
|
|
0294038fd3 | ||
|
|
a1fa4fe874 | ||
|
|
192173e515 | ||
|
|
921fe4212f | ||
|
|
f2b71b67d4 | ||
|
|
a059fa213e | ||
|
|
1bd69e693a | ||
|
|
c52169d5d8 | ||
|
|
860f79e725 | ||
|
|
3c02a0a478 | ||
|
|
fe0a7713df | ||
|
|
2dd1ff76ed | ||
|
|
ee5b94099a | ||
|
|
eeb3f2491a | ||
|
|
683daa032e | ||
|
|
7e11129297 | ||
|
|
b7a94f1a52 | ||
|
|
58c5eb8ecc | ||
|
|
794c27f6b6 | ||
|
|
cdb2e4cf41 | ||
|
|
9e4cf9dfbf | ||
|
|
34fd4a6bcd | ||
|
|
2332ee6347 | ||
|
|
eae62a4b42 | ||
|
|
d4d9ae5252 | ||
|
|
fba79b391a | ||
|
|
14474eccf1 | ||
|
|
ce2bfea91b | ||
|
|
b0639db79e | ||
|
|
207e3c631c | ||
|
|
23d7390286 | ||
|
|
51ba46c734 | ||
|
|
2fb7faf659 | ||
|
|
758f902b66 | ||
|
|
c40a6a949c | ||
|
|
2de1c4e766 | ||
|
|
81f80a01f4 | ||
|
|
368b9f2b59 | ||
|
|
8ad11f1728 | ||
|
|
4de7e559e9 | ||
|
|
e7c26a83db | ||
|
|
3785f0c977 | ||
|
|
87442788d4 | ||
|
|
62eeee5f7c | ||
|
|
a7ab624a01 | ||
|
|
7e182e88b2 | ||
|
|
4cfae93a0d | ||
|
|
99dc5e7f8b | ||
|
|
763aef09f8 | ||
|
|
d569a2afda | ||
|
|
1ca957499a | ||
|
|
9133ecdcd4 | ||
|
|
56ab1368b1 | ||
|
|
59f13a74f9 | ||
|
|
38d033f6a9 | ||
|
|
6045114ca2 | ||
|
|
e39a79bab0 | ||
|
|
a5be07ac0f | ||
|
|
4acfd21883 | ||
|
|
2a8ff29ccd | ||
|
|
976ebe752b | ||
|
|
1fb4b3ec5e | ||
|
|
675601869b | ||
|
|
b7da3054e1 | ||
|
|
c39256b1a0 | ||
|
|
9e0b004d93 | ||
|
|
795e2bae35 | ||
|
|
822d2afba1 | ||
|
|
9d34459191 | ||
|
|
e52467ff5d | ||
|
|
ae33bfe04b | ||
|
|
785cb72063 | ||
|
|
7e6ded29e2 | ||
|
|
3401654ab9 | ||
|
|
8154a59014 | ||
|
|
2e3456b21b | ||
|
|
f05bbba613 | ||
|
|
f6b9d650f8 | ||
|
|
3327728df0 | ||
|
|
ac2d64caaf | ||
|
|
bfff24fa5f | ||
|
|
34cd8ad26e | ||
|
|
a27b9abd9f | ||
|
|
045dbc65b7 | ||
|
|
35deedcc16 | ||
|
|
5abab067a1 | ||
|
|
6d685b7e8a | ||
|
|
24ba65c829 | ||
|
|
3bf2e8f262 | ||
|
|
8ce34b3ecc | ||
|
|
9c50c49e1f | ||
|
|
177571f411 | ||
|
|
51310160a8 | ||
|
|
2c13b21360 | ||
|
|
ad2fe5bbef | ||
|
|
58f8eae997 | ||
|
|
44fdcfa18b | ||
|
|
b1e025d468 | ||
|
|
9ebb421307 | ||
|
|
8a6758b591 | ||
|
|
6d02d366ce | ||
|
|
fe31be34a0 | ||
|
|
109fa14ccb | ||
|
|
06bf53ac26 | ||
|
|
dc66e22d4d | ||
|
|
a3cfffa4d0 | ||
|
|
23f881b797 | ||
|
|
e625addc3b | ||
|
|
536a166b35 | ||
|
|
e72345bad5 | ||
|
|
dfa8058efc | ||
|
|
41cefeedf6 | ||
|
|
1e5c66d6ab | ||
|
|
3458195865 | ||
|
|
24b3688df0 | ||
|
|
2d6e39eebc | ||
|
|
c749d72a6a | ||
|
|
f053b35c6f | ||
|
|
ca5f6a962c | ||
|
|
ed244ef743 | ||
|
|
ce46db7dbe | ||
|
|
a562f408d2 | ||
|
|
5b025b98bd | ||
|
|
4c3c3c882d | ||
|
|
d591d8e66b | ||
|
|
4db36b9099 | ||
|
|
cacbeeb068 | ||
|
|
d5f6366c28 | ||
|
|
2b757e556d | ||
|
|
fbd1de9220 | ||
|
|
fba8e74b72 | ||
|
|
6d4962639c | ||
|
|
fc011c6547 | ||
|
|
752abd69a5 | ||
|
|
570c2b689e | ||
|
|
17617137ea | ||
|
|
9a1572ea20 | ||
|
|
f48faa1803 | ||
|
|
6e5249aa1e | ||
|
|
695a571c1f | ||
|
|
bd4e71dd30 | ||
|
|
cd6a5d092c | ||
|
|
5aed055331 | ||
|
|
d6a45b8e12 | ||
|
|
daf24e4739 | ||
|
|
0c5bef34cf | ||
|
|
5615458223 | ||
|
|
9c9528b51b | ||
|
|
1009d7c976 | ||
|
|
790b12a7f3 | ||
|
|
8f4c052294 | ||
|
|
b7bff7bf6b | ||
|
|
748891ab31 | ||
|
|
17b8873f0f | ||
|
|
15c0055687 | ||
|
|
6315f366c8 | ||
|
|
784febc162 | ||
|
|
e2c700fcfc | ||
|
|
77a0f5b674 | ||
|
|
63dfa22255 | ||
|
|
1d22877aec | ||
|
|
e8175561d5 | ||
|
|
f3a8ebdb4b | ||
|
|
f0c76a1724 | ||
|
|
3e787b395f | ||
|
|
3d1c4c61c9 | ||
|
|
250d1f7c77 | ||
|
|
5eff9af69c | ||
|
|
b0990e30ee | ||
|
|
1629a8f994 | ||
|
|
55749bdfaf | ||
|
|
cd1c649d0f | ||
|
|
7724fe3503 | ||
|
|
1d56eebe87 | ||
|
|
10720400fd | ||
|
|
05cb22f1a5 | ||
|
|
26aa3c2d70 | ||
|
|
e56a63c59c | ||
|
|
221923857d | ||
|
|
62284e7a94 | ||
|
|
668e234eb2 | ||
|
|
4b02ab3d8a | ||
|
|
5d8b319451 | ||
|
|
77f75fb6e5 | ||
|
|
b41e6c250e | ||
|
|
6e720feca4 | ||
|
|
ae9fd26eba | ||
|
|
33b395d2bd | ||
|
|
8e8d1bd8c5 | ||
|
|
c7357336e3 | ||
|
|
b8ecc5c58b | ||
|
|
f4f0257447 | ||
|
|
ca351e9d73 |
@@ -1,19 +1,14 @@
|
|||||||
name: Build and Push Docker Images
|
name: Deploy (staging -> E2E gate -> production)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: privaterepo.sitaru.org
|
REGISTRY: privaterepo.sitaru.org
|
||||||
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||||
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||||
INTEGRATOR_IMAGE_NAME: ${{ gitea.repository }}-integrator
|
|
||||||
KESTRA_INIT_IMAGE_NAME: ${{ gitea.repository }}-kestra-init
|
|
||||||
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -47,17 +42,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=sha
|
||||||
type=ref,event=pr
|
type=raw,value=staging
|
||||||
type=sha,prefix=backend-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Backend Docker image
|
- name: Build and push Backend Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
push: true
|
||||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
||||||
@@ -93,112 +86,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=sha
|
||||||
type=ref,event=pr
|
type=raw,value=staging
|
||||||
type=sha,prefix=frontend-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Frontend Docker image
|
- name: Build and push Frontend Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./nextjs-app
|
context: ./nextjs-app
|
||||||
file: ./nextjs-app/Dockerfile
|
file: ./nextjs-app/Dockerfile
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
push: true
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
FASTAPI_URL=http://backend:80/api
|
FASTAPI_URL=http://backend:80/api
|
||||||
# Cache disabled due to registry size limits
|
# 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-integrator:
|
|
||||||
name: Build Integrator
|
|
||||||
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: Extract metadata for Integrator Docker image
|
|
||||||
id: meta-integrator
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.INTEGRATOR_IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha,prefix=integrator-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Integrator Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./integrator
|
|
||||||
file: ./integrator/Dockerfile
|
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta-integrator.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-integrator.outputs.labels }}
|
|
||||||
|
|
||||||
build-kestra-init:
|
|
||||||
name: Build Kestra Init
|
|
||||||
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: Extract metadata for Kestra Init Docker image
|
|
||||||
id: meta-kestra-init
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.KESTRA_INIT_IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha,prefix=kestra-init-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Kestra Init Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./integrator
|
|
||||||
file: ./integrator/Dockerfile.init
|
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta-kestra-init.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-kestra-init.outputs.labels }}
|
|
||||||
|
|
||||||
build-pipeline:
|
build-pipeline:
|
||||||
name: Build Pipeline (Meltano + dbt + Airflow)
|
name: Build Pipeline (Meltano + dbt + Airflow)
|
||||||
@@ -230,28 +131,110 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=sha
|
||||||
type=ref,event=pr
|
type=raw,value=staging
|
||||||
type=sha,prefix=pipeline-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Pipeline Docker image
|
- name: Build and push Pipeline Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./pipeline
|
context: ./pipeline
|
||||||
file: ./pipeline/Dockerfile
|
file: ./pipeline/Dockerfile
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
push: true
|
||||||
tags: ${{ steps.meta-pipeline.outputs.tags }}
|
tags: ${{ steps.meta-pipeline.outputs.tags }}
|
||||||
labels: ${{ steps.meta-pipeline.outputs.labels }}
|
labels: ${{ steps.meta-pipeline.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
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
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|
||||||
trigger-deployment:
|
deploy-staging:
|
||||||
name: Trigger Portainer Update
|
name: Deploy to Staging
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-backend, build-frontend, build-integrator, build-kestra-init, build-pipeline]
|
needs: [build-backend, build-frontend, build-pipeline]
|
||||||
if: gitea.event_name != 'pull_request'
|
|
||||||
steps:
|
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: |
|
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
|
||||||
@@ -22,13 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
COPY frontend/ ./frontend/
|
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY data/ ./data/
|
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Run the application (using module import)
|
# Run the application (using module import)
|
||||||
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Mobile design baseline
|
||||||
|
|
||||||
|
Mobile (≥55% of traffic) is the primary target for this app. Any new
|
||||||
|
screen or component must be designed at the **360 px** viewport first
|
||||||
|
and verified at three reference widths before merge.
|
||||||
|
|
||||||
|
## Reference viewports
|
||||||
|
|
||||||
|
| Width | Device class | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 360 px | Low-end Android (Samsung A-series, older Pixels) | Hard floor — if it doesn't fit here it isn't shipping |
|
||||||
|
| 390 px | iPhone 14 / 15 / 16 (38% of mobile traffic) | Primary iOS target |
|
||||||
|
| 430 px | iPhone 16 Pro Max, large Android | Upper mobile bound |
|
||||||
|
|
||||||
|
## Acceptance checks for any screen change
|
||||||
|
|
||||||
|
Before raising a PR that touches user-visible UI, confirm at each
|
||||||
|
reference width:
|
||||||
|
|
||||||
|
1. **No horizontal overflow.** `document.documentElement.scrollWidth ===
|
||||||
|
window.innerWidth`. The most reliable check: in DevTools console run
|
||||||
|
```js
|
||||||
|
document.documentElement.scrollWidth - innerWidth
|
||||||
|
```
|
||||||
|
It must read `0`. Any positive number means something is bleeding
|
||||||
|
past the right edge — usually a fixed-width element, an inline-block
|
||||||
|
that didn't wrap, or a flex row missing `flex-wrap: wrap`.
|
||||||
|
2. **Tap targets ≥ 44 × 44 px** on every interactive element (iOS Human
|
||||||
|
Interface Guidelines minimum). Probe with:
|
||||||
|
```js
|
||||||
|
Array.from(document.querySelectorAll('a, button, [role=button], input, select'))
|
||||||
|
.filter(el => el.offsetParent)
|
||||||
|
.map(el => ({ t: el.innerText?.trim().slice(0,30), r: el.getBoundingClientRect() }))
|
||||||
|
.filter(o => o.r.width < 44 || o.r.height < 44)
|
||||||
|
```
|
||||||
|
3. **No text below 11 px** in any visible-by-default block. Decorative
|
||||||
|
demo content (illustrations, mocked previews) should either scale up
|
||||||
|
or be hidden under the `640 px` breakpoint — see `MOB-04` for the
|
||||||
|
pattern used on the home page's "What you'll see" section.
|
||||||
|
4. **iOS Chrome bottom-bar parity.** The fixed `Navigation` bottom tab
|
||||||
|
bar already compensates for the auto-hiding URL bar via the Visual
|
||||||
|
Viewport API (`Navigation.tsx`). New fixed-bottom elements must
|
||||||
|
either use the same offset (read `var(--mobile-bar-offset)`) or sit
|
||||||
|
inside the existing tab-bar container.
|
||||||
|
5. **Safe-area insets** on any new sticky/fixed chrome:
|
||||||
|
`padding-bottom: env(safe-area-inset-bottom)` for bottom-pinned UI,
|
||||||
|
`padding-inline: env(safe-area-inset-left/right)` for header-class
|
||||||
|
chrome that runs full bleed.
|
||||||
|
6. **`dvh`, not `vh`.** iOS Safari's collapsing toolbar makes raw `vh`
|
||||||
|
units jump. Prefer `100dvh` (with a `100vh` fallback if you support
|
||||||
|
older engines) for any height that needs to track the visible
|
||||||
|
viewport.
|
||||||
|
|
||||||
|
## Component patterns
|
||||||
|
|
||||||
|
- **Hide-on-mobile decoration:** wrap with `@media (max-width: 640px) {
|
||||||
|
.x { display: none; } }` — examples in `HomeView.module.css`
|
||||||
|
(`.hiwVisual`), `MetricTooltip.module.css` (`.wrapper`).
|
||||||
|
- **Right-edge scroll-fade for horizontal scrollers:**
|
||||||
|
`mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);`
|
||||||
|
Drop the fade when scrolled to the end with a JS-toggled class — see
|
||||||
|
`SchoolDetailView.tsx`'s `sectionNavAtEnd` state for the pattern.
|
||||||
|
|
||||||
|
## Automation (future)
|
||||||
|
|
||||||
|
A Playwright regression test that asserts `docW === vw` at the three
|
||||||
|
reference widths on `/`, `/rankings`, `/admissions`, `/compare`, and a
|
||||||
|
representative `/school/:urn` page would catch overflow regressions
|
||||||
|
immediately. Not added yet — Playwright isn't currently in the project
|
||||||
|
dependency set, and the existing Jest setup doesn't compute layout.
|
||||||
|
Worth adding if mobile overflow regressions recur.
|
||||||
@@ -26,7 +26,7 @@ The application tracks these Key Stage 2 performance indicators:
|
|||||||
| **Reading Expected %** | Percentage meeting expected standard in reading |
|
| **Reading Expected %** | Percentage meeting expected standard in reading |
|
||||||
| **Writing Expected %** | Percentage meeting expected standard in writing |
|
| **Writing Expected %** | Percentage meeting expected standard in writing |
|
||||||
| **Maths Expected %** | Percentage meeting expected standard in maths |
|
| **Maths Expected %** | Percentage meeting expected standard in maths |
|
||||||
| **RWM Combined %** | Percentage meeting expected standard in all three subjects |
|
| **Reading, Writing & Maths Combined %** | Percentage meeting expected standard in all three subjects |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ If using your own CSV data, ensure it includes these columns (or similar):
|
|||||||
| READPROG | Float | Reading progress score |
|
| READPROG | Float | Reading progress score |
|
||||||
| WRITPROG | Float | Writing progress score |
|
| WRITPROG | Float | Writing progress score |
|
||||||
| MATPROG | Float | Maths progress score |
|
| MATPROG | Float | Maths progress score |
|
||||||
| PTRWM_EXP | Float | % meeting expected standard in RWM |
|
| PTRWM_EXP | Float | % meeting expected standard in reading, writing & maths |
|
||||||
| PTREAD_EXP | Float | % meeting expected standard in reading |
|
| PTREAD_EXP | Float | % meeting expected standard in reading |
|
||||||
| PTWRIT_EXP | Float | % meeting expected standard in writing |
|
| PTWRIT_EXP | Float | % meeting expected standard in writing |
|
||||||
| PTMAT_EXP | Float | % meeting expected standard in maths |
|
| PTMAT_EXP | Float | % meeting expected standard in maths |
|
||||||
|
|||||||
+408
-109
@@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
SchoolCompare.co.uk API
|
SchoolCompare.co.uk API
|
||||||
Serves primary school (KS2) performance data for comparing schools.
|
Serves primary and secondary school performance data for comparing schools.
|
||||||
Uses real data from UK Government Compare School Performance downloads.
|
Uses real data from UK Government Compare School Performance downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -12,6 +13,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
@@ -24,15 +26,94 @@ from .config import settings
|
|||||||
from .data_loader import (
|
from .data_loader import (
|
||||||
clear_cache,
|
clear_cache,
|
||||||
load_school_data,
|
load_school_data,
|
||||||
|
load_latest_school_data,
|
||||||
geocode_single_postcode,
|
geocode_single_postcode,
|
||||||
get_supplementary_data,
|
get_supplementary_data,
|
||||||
|
search_schools_typesense,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
from .database import check_and_migrate_if_needed
|
|
||||||
from .migration import run_full_migration
|
|
||||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json
|
from .utils import clean_for_json
|
||||||
|
|
||||||
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
|
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||||
|
|
||||||
|
# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they include.
|
||||||
|
# All-through schools appear in both primary and secondary results.
|
||||||
|
PHASE_GROUPS: dict[str, set[str]] = {
|
||||||
|
"primary": {"primary", "middle deemed primary", "all-through"},
|
||||||
|
"secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"},
|
||||||
|
"all-through": {"all-through"},
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_URL = "https://schoolcompare.co.uk"
|
||||||
|
MAX_SLUG_LENGTH = 60
|
||||||
|
|
||||||
|
# In-memory sitemap cache
|
||||||
|
_sitemap_xml: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(text: str) -> str:
|
||||||
|
text = text.lower()
|
||||||
|
text = re.sub(r"[^\w\s-]", "", text)
|
||||||
|
text = re.sub(r"\s+", "-", text)
|
||||||
|
text = re.sub(r"-+", "-", text)
|
||||||
|
return text.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _school_url(urn: int, school_name: str) -> str:
|
||||||
|
slug = _slugify(school_name)
|
||||||
|
if len(slug) > MAX_SLUG_LENGTH:
|
||||||
|
slug = slug[:MAX_SLUG_LENGTH].rstrip("-")
|
||||||
|
return f"/school/{urn}-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemap() -> str:
|
||||||
|
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
||||||
|
df = load_school_data()
|
||||||
|
|
||||||
|
static_urls = [
|
||||||
|
(BASE_URL + "/", "daily", "1.0"),
|
||||||
|
(BASE_URL + "/rankings", "weekly", "0.8"),
|
||||||
|
(BASE_URL + "/compare", "weekly", "0.8"),
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||||
|
|
||||||
|
for url, freq, priority in static_urls:
|
||||||
|
lines.append(
|
||||||
|
f" <url><loc>{url}</loc>"
|
||||||
|
f"<changefreq>{freq}</changefreq>"
|
||||||
|
f"<priority>{priority}</priority></url>"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
|
||||||
|
seen = set()
|
||||||
|
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
||||||
|
urn = int(row["urn"])
|
||||||
|
name = str(row["school_name"])
|
||||||
|
if urn in seen:
|
||||||
|
continue
|
||||||
|
seen.add(urn)
|
||||||
|
path = _school_url(urn, name)
|
||||||
|
lines.append(
|
||||||
|
f" <url><loc>{BASE_URL}{path}</loc>"
|
||||||
|
f"<changefreq>monthly</changefreq>"
|
||||||
|
f"<priority>0.6</priority></url>"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("</urlset>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||||
|
"""Return sorted unique values from a Series, excluding NaN and junk labels."""
|
||||||
|
return sorted(
|
||||||
|
v for v in series.dropna().unique().tolist()
|
||||||
|
if v not in EXCLUDED_FILTER_VALUES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SECURITY MIDDLEWARE & HELPERS
|
# SECURITY MIDDLEWARE & HELPERS
|
||||||
@@ -86,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
|
||||||
|
# Values: (max_age, s_maxage, stale_while_revalidate)
|
||||||
|
CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
|
||||||
|
("/api/filters", (300, 86400, 604800)),
|
||||||
|
("/api/metrics", (300, 86400, 604800)),
|
||||||
|
("/api/national-averages", (300, 86400, 604800)),
|
||||||
|
("/api/la-averages", (300, 86400, 604800)),
|
||||||
|
("/api/data-info", (300, 86400, 604800)),
|
||||||
|
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
|
||||||
|
("/api/rankings", (60, 600, 3600)),
|
||||||
|
("/api/compare", (60, 600, 3600)),
|
||||||
|
("/api/schools", (30, 300, 1800)), # search list
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_control_for_path(path: str) -> Optional[str]:
|
||||||
|
# Longest-prefix match
|
||||||
|
best: Optional[tuple[int, tuple[int, int, int]]] = None
|
||||||
|
for prefix, vals in CACHE_RULES:
|
||||||
|
if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
|
||||||
|
best = (len(prefix), vals)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
max_age, s_maxage, swr = best[1]
|
||||||
|
return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheAndETagMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Set Cache-Control on cacheable API responses and serve 304s via ETag."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Only cache GETs that succeeded.
|
||||||
|
if request.method != "GET" or response.status_code != 200:
|
||||||
|
return response
|
||||||
|
|
||||||
|
cache_header = _cache_control_for_path(request.url.path)
|
||||||
|
if cache_header is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Drain body so we can hash it for ETag.
|
||||||
|
body_chunks = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
body_chunks.append(chunk)
|
||||||
|
body = b"".join(body_chunks)
|
||||||
|
|
||||||
|
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||||
|
headers = dict(response.headers)
|
||||||
|
headers["Cache-Control"] = cache_header
|
||||||
|
headers["ETag"] = etag
|
||||||
|
headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
|
||||||
|
|
||||||
|
inm = request.headers.get("if-none-match")
|
||||||
|
if inm and inm == etag:
|
||||||
|
# Strip content headers on 304.
|
||||||
|
for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
|
||||||
|
headers.pop(h, None)
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
|
||||||
|
return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||||
|
|
||||||
|
|
||||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
"""Limit request body size to prevent DoS attacks."""
|
"""Limit request body size to prevent DoS attacks."""
|
||||||
|
|
||||||
@@ -138,26 +282,30 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan - startup and shutdown events."""
|
"""Application lifespan - startup and shutdown events."""
|
||||||
# Startup: check schema version and migrate if needed
|
global _sitemap_xml
|
||||||
print("Starting up: Checking database schema...")
|
print("Loading school data from marts...")
|
||||||
check_and_migrate_if_needed()
|
|
||||||
|
|
||||||
print("Loading school data from database...")
|
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
if df.empty:
|
if df.empty:
|
||||||
print("Warning: No data in database. Check CSV files in data/ folder.")
|
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
||||||
else:
|
else:
|
||||||
print(f"Data loaded successfully: {len(df)} records.")
|
print(f"Data loaded successfully: {len(df)} records.")
|
||||||
|
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||||
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
|
try:
|
||||||
|
_sitemap_xml = build_sitemap()
|
||||||
|
n = _sitemap_xml.count("<url>")
|
||||||
|
print(f"Sitemap built: {n} URLs.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: sitemap build failed on startup: {e}")
|
||||||
|
|
||||||
yield # Application runs here
|
yield
|
||||||
|
|
||||||
# Shutdown: cleanup if needed
|
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SchoolCompare API",
|
title="SchoolCompare API",
|
||||||
description="API for comparing primary school (KS2) performance data - schoolcompare.co.uk",
|
description="API for comparing primary and secondary school performance data - schoolcompare.co.uk",
|
||||||
version="2.0.0",
|
version="2.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
# Disable docs in production for security
|
# Disable docs in production for security
|
||||||
@@ -170,9 +318,12 @@ app = FastAPI(
|
|||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# Security middleware (order matters - these run in reverse order)
|
# Middleware (Starlette runs the last-added middleware first on the way out,
|
||||||
|
# so list outermost-last: GZip wraps everything and compresses the final body).
|
||||||
|
app.add_middleware(CacheAndETagMiddleware)
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
app.add_middleware(RequestSizeLimitMiddleware)
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=512)
|
||||||
|
|
||||||
# CORS middleware - restricted for production
|
# CORS middleware - restricted for production
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -219,69 +370,86 @@ async def get_schools(
|
|||||||
None, description="Filter by local authority", max_length=100
|
None, description="Filter by local authority", max_length=100
|
||||||
),
|
),
|
||||||
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
||||||
|
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
|
||||||
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
||||||
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
|
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
|
||||||
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
||||||
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
|
page_size: int = Query(25, ge=1, le=500, description="Results per page"),
|
||||||
|
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
|
||||||
|
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
|
||||||
|
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of unique primary schools with pagination.
|
Get list of schools with pagination.
|
||||||
|
|
||||||
Returns paginated results with total count for efficient loading.
|
Returns paginated results with total count for efficient loading.
|
||||||
Supports location-based search using postcode.
|
Supports location-based search using postcode and phase filtering.
|
||||||
"""
|
"""
|
||||||
# Sanitize inputs
|
# Sanitize inputs
|
||||||
search = sanitize_search_input(search)
|
search = sanitize_search_input(search)
|
||||||
local_authority = sanitize_search_input(local_authority)
|
local_authority = sanitize_search_input(local_authority)
|
||||||
school_type = sanitize_search_input(school_type)
|
school_type = sanitize_search_input(school_type)
|
||||||
|
phase = sanitize_search_input(phase)
|
||||||
postcode = validate_postcode(postcode)
|
postcode = validate_postcode(postcode)
|
||||||
|
|
||||||
df = load_school_data()
|
# Load the pre-computed latest-year snapshot (cached after first request / startup).
|
||||||
|
# This avoids rebuilding the expensive groupby + prev-year merge on every search.
|
||||||
|
df_latest = load_latest_school_data()
|
||||||
|
|
||||||
if df.empty:
|
if df_latest.empty:
|
||||||
return {"schools": [], "total": 0, "page": page, "page_size": 0}
|
return {"schools": [], "total": 0, "page": page, "page_size": 0}
|
||||||
|
|
||||||
# Use configured default if not specified
|
# Use configured default if not specified
|
||||||
if page_size is None:
|
if page_size is None:
|
||||||
page_size = settings.default_page_size
|
page_size = settings.default_page_size
|
||||||
|
|
||||||
# Get unique schools (latest year data for each)
|
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
# in the correct phase(s) rather than being invisible to both filters.
|
||||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
if phase:
|
||||||
|
phase_lower = phase.lower().replace("_", "-")
|
||||||
|
allowed = PHASE_GROUPS.get(phase_lower)
|
||||||
|
if allowed:
|
||||||
|
df_latest = df_latest[df_latest["phase"].str.lower().isin(allowed)]
|
||||||
|
|
||||||
# Calculate trend by comparing to previous year
|
# Secondary-specific filters (after phase filter)
|
||||||
# Get second-latest year for each school
|
if gender:
|
||||||
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
|
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
||||||
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
if admissions_policy:
|
||||||
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
||||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
if has_sixth_form == "yes":
|
||||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
||||||
)
|
elif has_sixth_form == "no":
|
||||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
||||||
|
|
||||||
# Include key result metrics for display on cards
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
result_cols = [
|
result_cols = [
|
||||||
|
"phase",
|
||||||
"year",
|
"year",
|
||||||
"rwm_expected_pct",
|
"rwm_expected_pct",
|
||||||
"rwm_high_pct",
|
"rwm_high_pct",
|
||||||
"prev_rwm_expected_pct",
|
"prev_rwm_expected_pct",
|
||||||
|
"prev_attainment_8_score",
|
||||||
"reading_expected_pct",
|
"reading_expected_pct",
|
||||||
"writing_expected_pct",
|
"writing_expected_pct",
|
||||||
"maths_expected_pct",
|
"maths_expected_pct",
|
||||||
"total_pupils",
|
"total_pupils",
|
||||||
|
"attainment_8_score",
|
||||||
|
"english_maths_standard_pass_pct",
|
||||||
]
|
]
|
||||||
available_cols = [
|
available_cols = [
|
||||||
c
|
c
|
||||||
for c in SCHOOL_COLUMNS + location_cols + result_cols
|
for c in SCHOOL_COLUMNS + location_cols + result_cols
|
||||||
if c in df_latest.columns
|
if c in df_latest.columns
|
||||||
]
|
]
|
||||||
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
|
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
|
||||||
|
schools_df = df_latest[available_cols]
|
||||||
|
|
||||||
# Location-based search (uses pre-geocoded data from database)
|
# Location-based search (uses pre-geocoded data from database)
|
||||||
search_coords = None
|
search_coords = None
|
||||||
if postcode:
|
if postcode:
|
||||||
coords = geocode_single_postcode(postcode)
|
# Offload the synchronous HTTP call to a thread so the event loop stays free
|
||||||
|
coords = await asyncio.to_thread(geocode_single_postcode, postcode)
|
||||||
if coords:
|
if coords:
|
||||||
search_coords = coords
|
search_coords = coords
|
||||||
schools_df = schools_df.copy()
|
schools_df = schools_df.copy()
|
||||||
@@ -321,15 +489,19 @@ async def get_schools(
|
|||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if search:
|
if search:
|
||||||
search_lower = search.lower()
|
ts_urns = search_schools_typesense(search)
|
||||||
mask = (
|
if ts_urns:
|
||||||
schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
urn_order = {urn: i for i, urn in enumerate(ts_urns)}
|
||||||
)
|
schools_df = schools_df[schools_df["urn"].isin(set(ts_urns))].copy()
|
||||||
if "address" in schools_df.columns:
|
schools_df["_ts_rank"] = schools_df["urn"].map(urn_order)
|
||||||
mask = mask | schools_df["address"].str.lower().str.contains(
|
schools_df = schools_df.sort_values("_ts_rank").drop(columns=["_ts_rank"])
|
||||||
search_lower, na=False
|
else:
|
||||||
)
|
# Fallback: Typesense unavailable, use substring match
|
||||||
schools_df = schools_df[mask]
|
search_lower = search.lower()
|
||||||
|
mask = schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
||||||
|
if "address" in schools_df.columns:
|
||||||
|
mask = mask | schools_df["address"].str.lower().str.contains(search_lower, na=False)
|
||||||
|
schools_df = schools_df[mask]
|
||||||
|
|
||||||
if local_authority:
|
if local_authority:
|
||||||
schools_df = schools_df[
|
schools_df = schools_df[
|
||||||
@@ -341,6 +513,18 @@ async def get_schools(
|
|||||||
schools_df["school_type"].str.lower() == school_type.lower()
|
schools_df["school_type"].str.lower() == school_type.lower()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Compute result-scoped filter values (before pagination).
|
||||||
|
# Gender and admissions are secondary-only filters — scope them to schools
|
||||||
|
# with KS4 data so they don't appear for purely primary result sets.
|
||||||
|
_sec_mask = schools_df["attainment_8_score"].notna() if "attainment_8_score" in schools_df.columns else pd.Series(False, index=schools_df.index)
|
||||||
|
result_filters = {
|
||||||
|
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
|
||||||
|
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
|
||||||
|
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
|
||||||
|
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
|
||||||
|
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
|
||||||
|
}
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
total = len(schools_df)
|
total = len(schools_df)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -353,6 +537,7 @@ async def get_schools(
|
|||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||||
|
"result_filters": result_filters,
|
||||||
"location_info": {
|
"location_info": {
|
||||||
"postcode": postcode,
|
"postcode": postcode,
|
||||||
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
||||||
@@ -366,7 +551,7 @@ async def get_schools(
|
|||||||
@app.get("/api/schools/{urn}")
|
@app.get("/api/schools/{urn}")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_school_details(request: Request, urn: int):
|
async def get_school_details(request: Request, urn: int):
|
||||||
"""Get detailed KS2 data for a specific primary school across all years."""
|
"""Get detailed performance data for a specific school across all years."""
|
||||||
# Validate URN range (UK school URNs are 6 digits)
|
# Validate URN range (UK school URNs are 6 digits)
|
||||||
if not (100000 <= urn <= 999999):
|
if not (100000 <= urn <= 999999):
|
||||||
raise HTTPException(status_code=400, detail="Invalid URN format")
|
raise HTTPException(status_code=400, detail="Invalid URN format")
|
||||||
@@ -387,7 +572,7 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
# Get latest info for the school
|
# Get latest info for the school
|
||||||
latest = school_data.iloc[-1]
|
latest = school_data.iloc[-1]
|
||||||
|
|
||||||
# Fetch supplementary data (Ofsted, Parent View, admissions, etc.)
|
# Fetch supplementary data (Ofsted, admissions, etc.)
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
supplementary = {}
|
supplementary = {}
|
||||||
try:
|
try:
|
||||||
@@ -408,20 +593,21 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": "Primary",
|
"phase": latest.get("phase"),
|
||||||
# GIAS fields
|
# GIAS fields
|
||||||
"website": latest.get("website"),
|
"website": latest.get("website"),
|
||||||
"headteacher_name": latest.get("headteacher_name"),
|
"headteacher_name": latest.get("headteacher_name"),
|
||||||
"capacity": latest.get("capacity"),
|
"capacity": latest.get("capacity"),
|
||||||
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
},
|
},
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
"parent_view": supplementary.get("parent_view"),
|
|
||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
"sen_detail": supplementary.get("sen_detail"),
|
"sen_detail": supplementary.get("sen_detail"),
|
||||||
"phonics": supplementary.get("phonics"),
|
"phonics": supplementary.get("phonics"),
|
||||||
"deprivation": supplementary.get("deprivation"),
|
"deprivation": supplementary.get("deprivation"),
|
||||||
@@ -435,7 +621,7 @@ async def compare_schools(
|
|||||||
request: Request,
|
request: Request,
|
||||||
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
|
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
|
||||||
):
|
):
|
||||||
"""Compare multiple primary schools side by side."""
|
"""Compare multiple schools side by side."""
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
@@ -468,7 +654,11 @@ async def compare_schools(
|
|||||||
"urn": urn,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
|
"school_type": latest.get("school_type", ""),
|
||||||
"address": latest.get("address", ""),
|
"address": latest.get("address", ""),
|
||||||
|
"phase": latest.get("phase", ""),
|
||||||
|
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
|
||||||
|
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
|
||||||
},
|
},
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
}
|
}
|
||||||
@@ -489,10 +679,132 @@ async def get_filter_options(request: Request):
|
|||||||
"years": [],
|
"years": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Phases: return values from data, ordered sensibly
|
||||||
|
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
|
||||||
|
|
||||||
|
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
|
||||||
|
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
|
||||||
|
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
|
||||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
|
||||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||||
|
"phases": phases,
|
||||||
|
"genders": genders,
|
||||||
|
"admissions_policies": admissions_policies,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/la-averages")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def get_la_averages(request: Request):
|
||||||
|
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
|
||||||
|
df = load_school_data()
|
||||||
|
if df.empty:
|
||||||
|
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
|
||||||
|
latest_year = int(df["year"].max())
|
||||||
|
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
|
||||||
|
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
|
||||||
|
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/national-averages")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def get_national_averages(request: Request):
|
||||||
|
"""
|
||||||
|
Compute national average for each metric from the latest data year.
|
||||||
|
Returns separate averages for primary (KS2) and secondary (KS4) schools.
|
||||||
|
Values are derived from the loaded DataFrame so they automatically
|
||||||
|
stay current when new data is loaded.
|
||||||
|
"""
|
||||||
|
df = load_school_data()
|
||||||
|
if df.empty:
|
||||||
|
return {"primary": {}, "secondary": {}}
|
||||||
|
|
||||||
|
ks2_metrics = [
|
||||||
|
"rwm_expected_pct", "rwm_high_pct",
|
||||||
|
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||||
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
|
"overall_absence_pct", "persistent_absence_pct",
|
||||||
|
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
|
||||||
|
]
|
||||||
|
ks4_metrics = [
|
||||||
|
"attainment_8_score", "progress_8_score",
|
||||||
|
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
|
||||||
|
"ebacc_avg_score", "gcse_grade_91_pct",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _means(sub_df, metric_list):
|
||||||
|
out = {}
|
||||||
|
for col in metric_list:
|
||||||
|
if col in sub_df.columns:
|
||||||
|
val = sub_df[col].dropna()
|
||||||
|
if len(val) > 0:
|
||||||
|
out[col] = round(float(val.mean()), 2)
|
||||||
|
return out
|
||||||
|
|
||||||
|
latest_year = int(df["year"].max())
|
||||||
|
df_latest = df[df["year"] == latest_year]
|
||||||
|
|
||||||
|
# Primary: schools where KS2 data is non-null
|
||||||
|
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
|
||||||
|
# Secondary: schools where KS4 data is non-null
|
||||||
|
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
|
||||||
|
|
||||||
|
latest_primary = _means(primary_df, ks2_metrics)
|
||||||
|
latest_secondary = _means(secondary_df, ks4_metrics)
|
||||||
|
|
||||||
|
# Per-year KS2 primary averages: use official DfE figures from the mart table.
|
||||||
|
# Per-year KS4 secondary averages: computed from our dataset (no DfE dataset yet).
|
||||||
|
from .database import SessionLocal
|
||||||
|
from .models import Ks2NationalAverage
|
||||||
|
|
||||||
|
by_year = []
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||||
|
# Build a lookup of computed secondary averages per year as fallback
|
||||||
|
secondary_by_year = {}
|
||||||
|
for yr in sorted(df["year"].dropna().unique()):
|
||||||
|
yr = int(yr)
|
||||||
|
df_yr = df[df["year"] == yr]
|
||||||
|
secondary_by_year[yr] = _means(
|
||||||
|
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
|
||||||
|
)
|
||||||
|
# Merge: official KS2 figures + computed KS4 figures per year
|
||||||
|
ks2_years = {r.year for r in nat_rows}
|
||||||
|
all_years = sorted(ks2_years | set(secondary_by_year.keys()))
|
||||||
|
nat_lookup = {r.year: r for r in nat_rows}
|
||||||
|
for yr in all_years:
|
||||||
|
primary_yr: dict = {}
|
||||||
|
if yr in nat_lookup:
|
||||||
|
r = nat_lookup[yr]
|
||||||
|
for col in ks2_metrics:
|
||||||
|
val = getattr(r, col, None)
|
||||||
|
if val is not None:
|
||||||
|
primary_yr[col] = val
|
||||||
|
by_year.append({
|
||||||
|
"year": yr,
|
||||||
|
"primary": primary_yr,
|
||||||
|
"secondary": secondary_by_year.get(yr, {}),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Update latest_primary with official DfE figure for the latest year if available
|
||||||
|
if by_year:
|
||||||
|
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
|
||||||
|
if latest_official:
|
||||||
|
latest_primary = latest_official
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": latest_year,
|
||||||
|
"primary": latest_primary,
|
||||||
|
"secondary": latest_secondary,
|
||||||
|
"by_year": by_year,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -500,7 +812,7 @@ async def get_filter_options(request: Request):
|
|||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_available_metrics(request: Request):
|
async def get_available_metrics(request: Request):
|
||||||
"""
|
"""
|
||||||
Get list of available KS2 performance metrics for primary schools.
|
Get list of available performance metrics for schools.
|
||||||
|
|
||||||
This is the single source of truth for metric definitions.
|
This is the single source of truth for metric definitions.
|
||||||
Frontend should consume this to avoid duplication.
|
Frontend should consume this to avoid duplication.
|
||||||
@@ -519,16 +831,22 @@ async def get_available_metrics(request: Request):
|
|||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_rankings(
|
async def get_rankings(
|
||||||
request: Request,
|
request: Request,
|
||||||
metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by", max_length=50),
|
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
||||||
year: Optional[int] = Query(
|
year: Optional[int] = Query(
|
||||||
None, 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"),
|
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
|
||||||
local_authority: Optional[str] = Query(
|
local_authority: Optional[str] = Query(
|
||||||
None, description="Filter by local authority", max_length=100
|
None, description="Filter by local authority", max_length=100
|
||||||
),
|
),
|
||||||
|
phase: Optional[str] = Query(
|
||||||
|
None, description="Filter by phase: primary or secondary", max_length=20
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Get primary school rankings by a specific KS2 metric."""
|
"""Get school rankings by a specific metric."""
|
||||||
# Sanitize local authority input
|
# Sanitize local authority input
|
||||||
local_authority = sanitize_search_input(local_authority)
|
local_authority = sanitize_search_input(local_authority)
|
||||||
|
|
||||||
@@ -556,6 +874,12 @@ async def get_rankings(
|
|||||||
if local_authority:
|
if local_authority:
|
||||||
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
||||||
|
|
||||||
|
# Filter by phase
|
||||||
|
if phase == "primary" and "rwm_expected_pct" in df.columns:
|
||||||
|
df = df[df["rwm_expected_pct"].notna()]
|
||||||
|
elif phase == "secondary" and "attainment_8_score" in df.columns:
|
||||||
|
df = df[df["attainment_8_score"].notna()]
|
||||||
|
|
||||||
# Sort and rank (exclude rows with no data for this metric)
|
# Sort and rank (exclude rows with no data for this metric)
|
||||||
df = df.dropna(subset=[metric])
|
df = df.dropna(subset=[metric])
|
||||||
total = len(df)
|
total = len(df)
|
||||||
@@ -565,7 +889,12 @@ async def get_rankings(
|
|||||||
|
|
||||||
# Return only relevant fields for rankings
|
# Return only relevant fields for rankings
|
||||||
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
|
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
|
||||||
df = df[available_cols]
|
df = df[available_cols].copy()
|
||||||
|
|
||||||
|
# Surface the requested metric under a stable `value` key so the
|
||||||
|
# frontend doesn't need to know each metric's column name. The raw
|
||||||
|
# metric column is also kept in the row for callers that want it.
|
||||||
|
df["value"] = df[metric]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
@@ -585,7 +914,7 @@ async def get_data_info(request: Request):
|
|||||||
if db_info["total_schools"] == 0:
|
if db_info["total_schools"] == 0:
|
||||||
return {
|
return {
|
||||||
"status": "no_data",
|
"status": "no_data",
|
||||||
"message": "No data in database. Run the migration script: python scripts/migrate_csv_to_db.py",
|
"message": "No data in marts. Run the annual EES pipeline to load KS2 data.",
|
||||||
"data_source": "PostgreSQL",
|
"data_source": "PostgreSQL",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,10 +928,10 @@ async def get_data_info(request: Request):
|
|||||||
"data_source": "PostgreSQL",
|
"data_source": "PostgreSQL",
|
||||||
}
|
}
|
||||||
|
|
||||||
years = [int(y) for y in sorted(df["year"].unique())]
|
years = [int(y) for y in sorted(df["year"].dropna().unique())]
|
||||||
schools_per_year = {
|
schools_per_year = {
|
||||||
str(int(k)): int(v)
|
str(int(k)): int(v)
|
||||||
for k, v in df.groupby("year")["urn"].nunique().to_dict().items()
|
for k, v in df.dropna(subset=["year"]).groupby("year")["urn"].nunique().to_dict().items()
|
||||||
}
|
}
|
||||||
la_counts = {
|
la_counts = {
|
||||||
str(k): int(v)
|
str(k): int(v)
|
||||||
@@ -631,60 +960,11 @@ async def reload_data(
|
|||||||
Requires X-API-Key header with valid admin API key.
|
Requires X-API-Key header with valid admin API key.
|
||||||
"""
|
"""
|
||||||
clear_cache()
|
clear_cache()
|
||||||
load_school_data()
|
await asyncio.to_thread(load_school_data)
|
||||||
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
return {"status": "reloaded"}
|
return {"status": "reloaded"}
|
||||||
|
|
||||||
|
|
||||||
_reimport_status: dict = {"running": False, "done": False, "error": None}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/reimport-ks2")
|
|
||||||
@limiter.limit("2/minute")
|
|
||||||
async def reimport_ks2(
|
|
||||||
request: Request,
|
|
||||||
geocode: bool = True,
|
|
||||||
_: bool = Depends(verify_admin_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Start a full KS2 CSV migration in the background and return immediately.
|
|
||||||
Poll GET /api/admin/reimport-ks2/status to check progress.
|
|
||||||
Pass ?geocode=false to skip postcode → lat/lng resolution.
|
|
||||||
Requires X-API-Key header with valid admin API key.
|
|
||||||
"""
|
|
||||||
global _reimport_status
|
|
||||||
if _reimport_status["running"]:
|
|
||||||
return {"status": "already_running"}
|
|
||||||
|
|
||||||
_reimport_status = {"running": True, "done": False, "error": None}
|
|
||||||
|
|
||||||
def _run():
|
|
||||||
global _reimport_status
|
|
||||||
try:
|
|
||||||
success = run_full_migration(geocode=geocode)
|
|
||||||
if not success:
|
|
||||||
_reimport_status = {"running": False, "done": False, "error": "No CSV data found"}
|
|
||||||
return
|
|
||||||
clear_cache()
|
|
||||||
load_school_data()
|
|
||||||
_reimport_status = {"running": False, "done": True, "error": None}
|
|
||||||
except Exception as exc:
|
|
||||||
_reimport_status = {"running": False, "done": False, "error": str(exc)}
|
|
||||||
|
|
||||||
import threading
|
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
|
||||||
return {"status": "started"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/reimport-ks2/status")
|
|
||||||
async def reimport_ks2_status(
|
|
||||||
request: Request,
|
|
||||||
_: bool = Depends(verify_admin_api_key)
|
|
||||||
):
|
|
||||||
"""Poll this endpoint to check reimport progress."""
|
|
||||||
s = _reimport_status
|
|
||||||
if s["error"]:
|
|
||||||
raise HTTPException(status_code=500, detail=s["error"])
|
|
||||||
return {"running": s["running"], "done": s["done"]}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -707,7 +987,26 @@ async def robots_txt():
|
|||||||
@app.get("/sitemap.xml")
|
@app.get("/sitemap.xml")
|
||||||
async def sitemap_xml():
|
async def sitemap_xml():
|
||||||
"""Serve sitemap.xml for search engine indexing."""
|
"""Serve sitemap.xml for search engine indexing."""
|
||||||
return FileResponse(settings.frontend_dir / "sitemap.xml", media_type="application/xml")
|
global _sitemap_xml
|
||||||
|
if _sitemap_xml is None:
|
||||||
|
try:
|
||||||
|
_sitemap_xml = build_sitemap()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||||
|
return Response(content=_sitemap_xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/regenerate-sitemap")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def regenerate_sitemap(
|
||||||
|
request: Request,
|
||||||
|
_: bool = Depends(verify_admin_api_key),
|
||||||
|
):
|
||||||
|
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||||
|
global _sitemap_xml
|
||||||
|
_sitemap_xml = build_sitemap()
|
||||||
|
n = _sitemap_xml.count("<url>")
|
||||||
|
return {"status": "ok", "urls": n}
|
||||||
|
|
||||||
|
|
||||||
# Mount static files directly (must be after all routes to avoid catching API calls)
|
# Mount static files directly (must be after all routes to avoid catching API calls)
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_burst: int = 10 # Allow burst of requests
|
rate_limit_burst: int = 10 # Allow burst of requests
|
||||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||||
|
|
||||||
|
# Typesense
|
||||||
|
typesense_url: str = "http://localhost:8108"
|
||||||
|
typesense_api_key: str = ""
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||||
|
|
||||||
|
|||||||
+406
-560
File diff suppressed because it is too large
Load Diff
+9
-113
@@ -1,36 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
Database connection setup using SQLAlchemy.
|
Database connection setup using SQLAlchemy.
|
||||||
|
The schema is managed by dbt — the backend only reads from marts.* tables.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, inspect
|
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
# Create engine
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_pre_ping=True, # Verify connections before use
|
pool_pre_ping=True,
|
||||||
echo=False, # Set to True for SQL debugging
|
pool_recycle=1800, # recycle connections every 30 min to avoid stale TCP
|
||||||
|
echo=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Session factory
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
# Base class for models
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""Dependency for FastAPI routes."""
|
||||||
Dependency for FastAPI routes to get a database session.
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
@@ -40,108 +35,9 @@ def get_db():
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db_session():
|
def get_db_session():
|
||||||
"""
|
"""Context manager for non-FastAPI contexts (read-only)."""
|
||||||
Context manager for database sessions.
|
|
||||||
Use in non-FastAPI contexts (scripts, etc).
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
|
||||||
"""
|
|
||||||
Initialize database - create all tables.
|
|
||||||
"""
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
def drop_db():
|
|
||||||
"""
|
|
||||||
Drop all tables - use with caution!
|
|
||||||
"""
|
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_schema_version() -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Get the current schema version from the database.
|
|
||||||
Returns None if table doesn't exist or no version is set.
|
|
||||||
"""
|
|
||||||
from .models import SchemaVersion # Import here to avoid circular imports
|
|
||||||
|
|
||||||
# Check if schema_version table exists
|
|
||||||
inspector = inspect(engine)
|
|
||||||
if "schema_version" not in inspector.get_table_names():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with get_db_session() as db:
|
|
||||||
row = db.query(SchemaVersion).first()
|
|
||||||
return row.version if row else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_db_schema_version(version: int):
|
|
||||||
"""
|
|
||||||
Set/update the schema version in the database.
|
|
||||||
Creates the row if it doesn't exist.
|
|
||||||
"""
|
|
||||||
from .models import SchemaVersion
|
|
||||||
|
|
||||||
with get_db_session() as db:
|
|
||||||
row = db.query(SchemaVersion).first()
|
|
||||||
if row:
|
|
||||||
row.version = version
|
|
||||||
row.migrated_at = datetime.utcnow()
|
|
||||||
else:
|
|
||||||
db.add(SchemaVersion(id=1, version=version, migrated_at=datetime.utcnow()))
|
|
||||||
|
|
||||||
|
|
||||||
def check_and_migrate_if_needed():
|
|
||||||
"""
|
|
||||||
Check schema version and run migration if needed.
|
|
||||||
Called during application startup.
|
|
||||||
"""
|
|
||||||
from .version import SCHEMA_VERSION
|
|
||||||
from .migration import run_full_migration
|
|
||||||
|
|
||||||
db_version = get_db_schema_version()
|
|
||||||
|
|
||||||
if db_version == SCHEMA_VERSION:
|
|
||||||
print(f"Schema version {SCHEMA_VERSION} matches. Fast startup.")
|
|
||||||
# Still ensure tables exist (they should if version matches)
|
|
||||||
init_db()
|
|
||||||
return
|
|
||||||
|
|
||||||
if db_version is None:
|
|
||||||
print(f"No schema version found. Running initial migration (v{SCHEMA_VERSION})...")
|
|
||||||
else:
|
|
||||||
print(f"Schema mismatch: DB has v{db_version}, code expects v{SCHEMA_VERSION}")
|
|
||||||
print("Running full migration...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Set schema version BEFORE migration so a crash mid-migration
|
|
||||||
# doesn't cause an infinite re-migration loop on every restart.
|
|
||||||
init_db()
|
|
||||||
set_db_schema_version(SCHEMA_VERSION)
|
|
||||||
|
|
||||||
success = run_full_migration(geocode=False)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(f"Migration complete. Schema version {SCHEMA_VERSION}.")
|
|
||||||
else:
|
|
||||||
print("Warning: Migration completed but no data was imported.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FATAL: Migration failed: {e}")
|
|
||||||
print("Application cannot start. Please check database and CSV files.")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,25 @@ def _apply_schema_alterations():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_schema_drops():
|
||||||
|
"""
|
||||||
|
Drop tables retired from the schema. Idempotent (DROP … IF EXISTS), so it's
|
||||||
|
safe to run on every migration. Add entries here when a model is removed.
|
||||||
|
"""
|
||||||
|
drops = [
|
||||||
|
# v6: Ofsted Parent View feature removed
|
||||||
|
"DROP TABLE IF EXISTS marts.fact_parent_view CASCADE",
|
||||||
|
]
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for stmt in drops:
|
||||||
|
try:
|
||||||
|
conn.execute(sa_text(stmt))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: drop skipped ({e})")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def run_full_migration(geocode: bool = False) -> bool:
|
def run_full_migration(geocode: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Run a complete migration: drop all tables and reimport from CSV.
|
Run a complete migration: drop all tables and reimport from CSV.
|
||||||
@@ -479,6 +498,9 @@ def run_full_migration(geocode: bool = False) -> bool:
|
|||||||
print("Applying column additions to supplementary tables...")
|
print("Applying column additions to supplementary tables...")
|
||||||
_apply_schema_alterations()
|
_apply_schema_alterations()
|
||||||
|
|
||||||
|
print("Dropping retired tables...")
|
||||||
|
_apply_schema_drops()
|
||||||
|
|
||||||
print("\nLoading CSV data...")
|
print("\nLoading CSV data...")
|
||||||
df = load_csv_data(settings.data_dir)
|
df = load_csv_data(settings.data_dir)
|
||||||
|
|
||||||
|
|||||||
+162
-332
@@ -1,408 +1,238 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy database models for school data.
|
SQLAlchemy models — all tables live in the marts schema, built by dbt.
|
||||||
Normalized schema with separate tables for schools and yearly results.
|
Read-only: the pipeline writes to these tables; the backend only reads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from sqlalchemy import Column, Integer, String, Float, Boolean, Date, Text, Index
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column, Integer, String, Float, ForeignKey, Index, UniqueConstraint,
|
|
||||||
Text, Boolean, DateTime, Date
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
MARTS = {"schema": "marts"}
|
||||||
|
|
||||||
class School(Base):
|
|
||||||
"""
|
class DimSchool(Base):
|
||||||
Core school information - relatively static data.
|
"""Canonical school dimension — one row per active URN."""
|
||||||
"""
|
__tablename__ = "dim_school"
|
||||||
__tablename__ = "schools"
|
__table_args__ = MARTS
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
urn = Column(Integer, unique=True, nullable=False, index=True)
|
|
||||||
school_name = Column(String(255), nullable=False)
|
school_name = Column(String(255), nullable=False)
|
||||||
local_authority = Column(String(100))
|
phase = Column(String(100))
|
||||||
local_authority_code = Column(Integer)
|
|
||||||
school_type = Column(String(100))
|
school_type = Column(String(100))
|
||||||
school_type_code = Column(String(10))
|
academy_trust_name = Column(String(255))
|
||||||
religious_denomination = Column(String(100))
|
academy_trust_uid = Column(String(20))
|
||||||
|
religious_character = Column(String(100))
|
||||||
|
gender = Column(String(20))
|
||||||
age_range = Column(String(20))
|
age_range = Column(String(20))
|
||||||
|
capacity = Column(Integer)
|
||||||
# Address
|
total_pupils = Column(Integer)
|
||||||
address1 = Column(String(255))
|
headteacher_name = Column(String(200))
|
||||||
address2 = Column(String(255))
|
website = Column(String(255))
|
||||||
|
telephone = Column(String(30))
|
||||||
|
status = Column(String(50))
|
||||||
|
nursery_provision = Column(Boolean)
|
||||||
|
admissions_policy = Column(String(50))
|
||||||
|
# Denormalised Ofsted summary (updated by monthly pipeline)
|
||||||
|
ofsted_grade = Column(Integer)
|
||||||
|
ofsted_date = Column(Date)
|
||||||
|
ofsted_framework = Column(String(20))
|
||||||
|
|
||||||
|
|
||||||
|
class DimLocation(Base):
|
||||||
|
"""School location — address, lat/lng from easting/northing (BNG→WGS84)."""
|
||||||
|
__tablename__ = "dim_location"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
address_line1 = Column(String(255))
|
||||||
|
address_line2 = Column(String(255))
|
||||||
town = Column(String(100))
|
town = Column(String(100))
|
||||||
postcode = Column(String(20), index=True)
|
county = Column(String(100))
|
||||||
|
postcode = Column(String(20))
|
||||||
# Geocoding (cached)
|
local_authority_code = Column(Integer)
|
||||||
|
local_authority_name = Column(String(100))
|
||||||
|
parliamentary_constituency = Column(String(100))
|
||||||
|
urban_rural = Column(String(50))
|
||||||
|
easting = Column(Integer)
|
||||||
|
northing = Column(Integer)
|
||||||
latitude = Column(Float)
|
latitude = Column(Float)
|
||||||
longitude = Column(Float)
|
longitude = Column(Float)
|
||||||
|
# geom is a PostGIS geometry — not mapped to SQLAlchemy (accessed via raw SQL)
|
||||||
# GIAS enrichment fields
|
|
||||||
website = Column(String(255))
|
|
||||||
headteacher_name = Column(String(200))
|
|
||||||
capacity = Column(Integer)
|
|
||||||
trust_name = Column(String(255))
|
|
||||||
trust_uid = Column(String(20))
|
|
||||||
gender = Column(String(20)) # Mixed / Girls / Boys
|
|
||||||
nursery_provision = Column(Boolean)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
results = relationship("SchoolResult", back_populates="school", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<School(urn={self.urn}, name='{self.school_name}')>"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def address(self):
|
|
||||||
"""Combine address fields into single string."""
|
|
||||||
parts = [self.address1, self.address2, self.town, self.postcode]
|
|
||||||
return ", ".join(p for p in parts if p)
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolResult(Base):
|
class KS2Performance(Base):
|
||||||
"""
|
"""KS2 attainment — one row per URN per year (includes predecessor data)."""
|
||||||
Yearly KS2 results for a school.
|
__tablename__ = "fact_ks2_performance"
|
||||||
Each school can have multiple years of results.
|
__table_args__ = (
|
||||||
"""
|
Index("ix_ks2_urn_year", "urn", "year"),
|
||||||
__tablename__ = "school_results"
|
MARTS,
|
||||||
|
)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
school_id = Column(Integer, ForeignKey("schools.id", ondelete="CASCADE"), nullable=False)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, nullable=False, index=True)
|
year = Column(Integer, primary_key=True)
|
||||||
|
source_urn = Column(Integer)
|
||||||
# Pupil numbers
|
|
||||||
total_pupils = Column(Integer)
|
total_pupils = Column(Integer)
|
||||||
eligible_pupils = Column(Integer)
|
eligible_pupils = Column(Integer)
|
||||||
|
# Core attainment
|
||||||
# Core KS2 metrics - Expected Standard
|
|
||||||
rwm_expected_pct = Column(Float)
|
rwm_expected_pct = Column(Float)
|
||||||
reading_expected_pct = Column(Float)
|
|
||||||
writing_expected_pct = Column(Float)
|
|
||||||
maths_expected_pct = Column(Float)
|
|
||||||
gps_expected_pct = Column(Float)
|
|
||||||
science_expected_pct = Column(Float)
|
|
||||||
|
|
||||||
# Higher Standard
|
|
||||||
rwm_high_pct = Column(Float)
|
rwm_high_pct = Column(Float)
|
||||||
|
reading_expected_pct = Column(Float)
|
||||||
reading_high_pct = Column(Float)
|
reading_high_pct = Column(Float)
|
||||||
writing_high_pct = Column(Float)
|
|
||||||
maths_high_pct = Column(Float)
|
|
||||||
gps_high_pct = Column(Float)
|
|
||||||
|
|
||||||
# Progress Scores
|
|
||||||
reading_progress = Column(Float)
|
|
||||||
writing_progress = Column(Float)
|
|
||||||
maths_progress = Column(Float)
|
|
||||||
|
|
||||||
# Average Scores
|
|
||||||
reading_avg_score = Column(Float)
|
reading_avg_score = Column(Float)
|
||||||
|
reading_progress = Column(Float)
|
||||||
|
writing_expected_pct = Column(Float)
|
||||||
|
writing_high_pct = Column(Float)
|
||||||
|
writing_progress = Column(Float)
|
||||||
|
maths_expected_pct = Column(Float)
|
||||||
|
maths_high_pct = Column(Float)
|
||||||
maths_avg_score = Column(Float)
|
maths_avg_score = Column(Float)
|
||||||
|
maths_progress = Column(Float)
|
||||||
|
gps_expected_pct = Column(Float)
|
||||||
|
gps_high_pct = Column(Float)
|
||||||
gps_avg_score = Column(Float)
|
gps_avg_score = Column(Float)
|
||||||
|
science_expected_pct = Column(Float)
|
||||||
# School Context
|
# Absence
|
||||||
|
reading_absence_pct = Column(Float)
|
||||||
|
writing_absence_pct = Column(Float)
|
||||||
|
maths_absence_pct = Column(Float)
|
||||||
|
gps_absence_pct = Column(Float)
|
||||||
|
science_absence_pct = Column(Float)
|
||||||
|
# Gender
|
||||||
|
rwm_expected_boys_pct = Column(Float)
|
||||||
|
rwm_high_boys_pct = Column(Float)
|
||||||
|
rwm_expected_girls_pct = Column(Float)
|
||||||
|
rwm_high_girls_pct = Column(Float)
|
||||||
|
# Disadvantaged
|
||||||
|
rwm_expected_disadvantaged_pct = Column(Float)
|
||||||
|
rwm_expected_non_disadvantaged_pct = Column(Float)
|
||||||
|
disadvantaged_gap = Column(Float)
|
||||||
|
# Context
|
||||||
disadvantaged_pct = Column(Float)
|
disadvantaged_pct = Column(Float)
|
||||||
eal_pct = Column(Float)
|
eal_pct = Column(Float)
|
||||||
sen_support_pct = Column(Float)
|
sen_support_pct = Column(Float)
|
||||||
sen_ehcp_pct = Column(Float)
|
sen_ehcp_pct = Column(Float)
|
||||||
stability_pct = Column(Float)
|
stability_pct = Column(Float)
|
||||||
|
|
||||||
# Pupil Absence from Tests
|
|
||||||
reading_absence_pct = Column(Float)
|
|
||||||
gps_absence_pct = Column(Float)
|
|
||||||
maths_absence_pct = Column(Float)
|
|
||||||
writing_absence_pct = Column(Float)
|
|
||||||
science_absence_pct = Column(Float)
|
|
||||||
|
|
||||||
# Gender Breakdown
|
class FactOfstedInspection(Base):
|
||||||
rwm_expected_boys_pct = Column(Float)
|
"""Full Ofsted inspection history — one row per inspection."""
|
||||||
rwm_expected_girls_pct = Column(Float)
|
__tablename__ = "fact_ofsted_inspection"
|
||||||
rwm_high_boys_pct = Column(Float)
|
|
||||||
rwm_high_girls_pct = Column(Float)
|
|
||||||
|
|
||||||
# Disadvantaged Performance
|
|
||||||
rwm_expected_disadvantaged_pct = Column(Float)
|
|
||||||
rwm_expected_non_disadvantaged_pct = Column(Float)
|
|
||||||
disadvantaged_gap = Column(Float)
|
|
||||||
|
|
||||||
# 3-Year Averages
|
|
||||||
rwm_expected_3yr_pct = Column(Float)
|
|
||||||
reading_avg_3yr = Column(Float)
|
|
||||||
maths_avg_3yr = Column(Float)
|
|
||||||
|
|
||||||
# Relationship
|
|
||||||
school = relationship("School", back_populates="results")
|
|
||||||
|
|
||||||
# Constraints
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('school_id', 'year', name='uq_school_year'),
|
Index("ix_ofsted_urn_date", "urn", "inspection_date"),
|
||||||
Index('ix_school_results_school_year', 'school_id', 'year'),
|
MARTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolResult(school_id={self.school_id}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchemaVersion(Base):
|
|
||||||
"""
|
|
||||||
Tracks database schema version for automatic migrations.
|
|
||||||
Single-row table that stores the current schema version.
|
|
||||||
"""
|
|
||||||
__tablename__ = "schema_version"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, default=1)
|
|
||||||
version = Column(Integer, nullable=False)
|
|
||||||
migrated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchemaVersion(version={self.version}, migrated_at={self.migrated_at})>"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Supplementary data tables (populated by the Kestra data integrator)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class OfstedInspection(Base):
|
|
||||||
"""Latest Ofsted inspection judgement per school."""
|
|
||||||
__tablename__ = "ofsted_inspections"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
inspection_date = Column(Date)
|
inspection_date = Column(Date, primary_key=True)
|
||||||
publication_date = Column(Date)
|
inspection_type = Column(String(100))
|
||||||
inspection_type = Column(String(100)) # Section 5 / Section 8 etc.
|
|
||||||
# Which inspection framework was used: 'OEIF' or 'ReportCard'
|
|
||||||
framework = Column(String(20))
|
framework = Column(String(20))
|
||||||
|
|
||||||
# --- OEIF grades (old framework, pre-Nov 2025) ---
|
|
||||||
# 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate
|
|
||||||
overall_effectiveness = Column(Integer)
|
overall_effectiveness = Column(Integer)
|
||||||
quality_of_education = Column(Integer)
|
quality_of_education = Column(Integer)
|
||||||
behaviour_attitudes = Column(Integer)
|
behaviour_attitudes = Column(Integer)
|
||||||
personal_development = Column(Integer)
|
personal_development = Column(Integer)
|
||||||
leadership_management = Column(Integer)
|
leadership_management = Column(Integer)
|
||||||
early_years_provision = Column(Integer) # nullable — not all schools
|
early_years_provision = Column(Integer)
|
||||||
previous_overall = Column(Integer) # for trend display
|
sixth_form_provision = Column(Integer)
|
||||||
|
# Ungraded (Section 8) inspection: raw outcome text and the grade parsed from
|
||||||
# --- Report Card grades (new framework, from Nov 2025) ---
|
# it (fallback for schools with no graded overall effectiveness).
|
||||||
# 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement
|
ungraded_outcome = Column(String(100))
|
||||||
rc_safeguarding_met = Column(Boolean) # True=Met, False=Not met
|
ungraded_grade = Column(Integer)
|
||||||
|
rc_safeguarding_met = Column(Boolean)
|
||||||
rc_inclusion = Column(Integer)
|
rc_inclusion = Column(Integer)
|
||||||
rc_curriculum_teaching = Column(Integer)
|
rc_curriculum_teaching = Column(Integer)
|
||||||
rc_achievement = Column(Integer)
|
rc_achievement = Column(Integer)
|
||||||
rc_attendance_behaviour = Column(Integer)
|
rc_attendance_behaviour = Column(Integer)
|
||||||
rc_personal_development = Column(Integer)
|
rc_personal_development = Column(Integer)
|
||||||
rc_leadership_governance = Column(Integer)
|
rc_leadership_governance = Column(Integer)
|
||||||
rc_early_years = Column(Integer) # nullable — not all schools
|
rc_early_years = Column(Integer)
|
||||||
rc_sixth_form = Column(Integer) # nullable — secondary only
|
rc_sixth_form = Column(Integer)
|
||||||
|
report_url = Column(Text)
|
||||||
def __repr__(self):
|
|
||||||
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
|
|
||||||
|
|
||||||
|
|
||||||
class OfstedParentView(Base):
|
class FactAdmissions(Base):
|
||||||
"""Ofsted Parent View survey — latest per school. 14 questions, % saying Yes."""
|
"""School admissions — one row per URN per year."""
|
||||||
__tablename__ = "ofsted_parent_view"
|
__tablename__ = "fact_admissions"
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
|
||||||
survey_date = Column(Date)
|
|
||||||
total_responses = Column(Integer)
|
|
||||||
q_happy_pct = Column(Float) # My child is happy at this school
|
|
||||||
q_safe_pct = Column(Float) # My child feels safe at this school
|
|
||||||
q_bullying_pct = Column(Float) # School deals with bullying well
|
|
||||||
q_communication_pct = Column(Float) # School keeps me informed
|
|
||||||
q_progress_pct = Column(Float) # My child does well / good progress
|
|
||||||
q_teaching_pct = Column(Float) # Teaching is good
|
|
||||||
q_information_pct = Column(Float) # I receive valuable info about progress
|
|
||||||
q_curriculum_pct = Column(Float) # Broad range of subjects taught
|
|
||||||
q_future_pct = Column(Float) # Prepares child well for the future
|
|
||||||
q_leadership_pct = Column(Float) # Led and managed effectively
|
|
||||||
q_wellbeing_pct = Column(Float) # Supports wider personal development
|
|
||||||
q_behaviour_pct = Column(Float) # Pupils are well behaved
|
|
||||||
q_recommend_pct = Column(Float) # I would recommend this school
|
|
||||||
q_sen_pct = Column(Float) # Good information about child's SEN (where applicable)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<OfstedParentView(urn={self.urn}, responses={self.total_responses})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolCensus(Base):
|
|
||||||
"""Annual school census snapshot — class sizes and ethnicity breakdown."""
|
|
||||||
__tablename__ = "school_census"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
|
||||||
year = Column(Integer, primary_key=True)
|
|
||||||
class_size_avg = Column(Float)
|
|
||||||
ethnicity_white_pct = Column(Float)
|
|
||||||
ethnicity_asian_pct = Column(Float)
|
|
||||||
ethnicity_black_pct = Column(Float)
|
|
||||||
ethnicity_mixed_pct = Column(Float)
|
|
||||||
ethnicity_other_pct = Column(Float)
|
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_school_census_urn_year', 'urn', 'year'),
|
Index("ix_admissions_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolCensus(urn={self.urn}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolAdmissions(Base):
|
|
||||||
"""Annual admissions statistics per school."""
|
|
||||||
__tablename__ = "school_admissions"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
published_admission_number = Column(Integer) # PAN
|
school_phase = Column(String(50))
|
||||||
|
places_offered = Column(Integer)
|
||||||
total_applications = Column(Integer)
|
total_applications = Column(Integer)
|
||||||
first_preference_offers_pct = Column(Float) # % receiving 1st choice
|
first_preference_applications = Column(Integer)
|
||||||
|
first_preference_offers = Column(Integer)
|
||||||
|
first_preference_offer_pct = Column(Float)
|
||||||
|
oversubscription_ratio = Column(Float)
|
||||||
oversubscribed = Column(Boolean)
|
oversubscribed = Column(Boolean)
|
||||||
|
admissions_policy = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
|
class FactPupilCharacteristics(Base):
|
||||||
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_school_admissions_urn_year', 'urn', 'year'),
|
Index("ix_pupil_chars_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolAdmissions(urn={self.urn}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SenDetail(Base):
|
|
||||||
"""SEN primary need type breakdown — more granular than school_results context fields."""
|
|
||||||
__tablename__ = "sen_detail"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
primary_need_speech_pct = Column(Float) # SLCN
|
phase_type_grouping = Column(String(50))
|
||||||
primary_need_autism_pct = Column(Float) # ASD
|
total_pupils = Column(Integer)
|
||||||
primary_need_mld_pct = Column(Float) # Moderate learning difficulty
|
female_pupils = Column(Integer)
|
||||||
primary_need_spld_pct = Column(Float) # Specific learning difficulty (dyslexia etc.)
|
male_pupils = Column(Integer)
|
||||||
primary_need_semh_pct = Column(Float) # Social, emotional, mental health
|
fsm_pct = Column(Float)
|
||||||
primary_need_physical_pct = Column(Float) # Physical/sensory
|
eal_pct = Column(Float)
|
||||||
primary_need_other_pct = Column(Float)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_sen_detail_urn_year', 'urn', 'year'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SenDetail(urn={self.urn}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class Phonics(Base):
|
class FactDeprivation(Base):
|
||||||
"""Phonics Screening Check pass rates."""
|
"""IDACI deprivation index — one row per URN."""
|
||||||
__tablename__ = "phonics"
|
__tablename__ = "fact_deprivation"
|
||||||
|
__table_args__ = MARTS
|
||||||
urn = Column(Integer, primary_key=True)
|
|
||||||
year = Column(Integer, primary_key=True)
|
|
||||||
year1_phonics_pct = Column(Float) # % reaching expected standard in Year 1
|
|
||||||
year2_phonics_pct = Column(Float) # % reaching standard in Year 2 (re-takers)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_phonics_urn_year', 'urn', 'year'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Phonics(urn={self.urn}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolDeprivation(Base):
|
|
||||||
"""IDACI deprivation index — derived via postcode → LSOA lookup."""
|
|
||||||
__tablename__ = "school_deprivation"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
lsoa_code = Column(String(20))
|
lsoa_code = Column(String(20))
|
||||||
idaci_score = Column(Float) # 0–1, higher = more deprived
|
idaci_score = Column(Float)
|
||||||
idaci_decile = Column(Integer) # 1 = most deprived, 10 = least deprived
|
idaci_decile = Column(Integer)
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolDeprivation(urn={self.urn}, decile={self.idaci_decile})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolFinance(Base):
|
class FactFinance(Base):
|
||||||
"""FBIT financial benchmarking data."""
|
"""FBIT financial benchmarking — one row per URN per year."""
|
||||||
__tablename__ = "school_finance"
|
__tablename__ = "fact_finance"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_finance_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
per_pupil_spend = Column(Float) # £ total expenditure per pupil
|
per_pupil_spend = Column(Float)
|
||||||
staff_cost_pct = Column(Float) # % of budget on all staff
|
staff_cost_pct = Column(Float)
|
||||||
teacher_cost_pct = Column(Float) # % on teachers specifically
|
teacher_cost_pct = Column(Float)
|
||||||
support_staff_cost_pct = Column(Float)
|
support_staff_cost_pct = Column(Float)
|
||||||
premises_cost_pct = Column(Float)
|
premises_cost_pct = Column(Float)
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_school_finance_urn_year', 'urn', 'year'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
class Ks2NationalAverage(Base):
|
||||||
return f"<SchoolFinance(urn={self.urn}, year={self.year})>"
|
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||||
|
__tablename__ = "fact_ks2_national_averages"
|
||||||
|
__table_args__ = MARTS
|
||||||
# Mapping from CSV columns to model fields
|
|
||||||
SCHOOL_FIELD_MAPPING = {
|
|
||||||
'urn': 'urn',
|
|
||||||
'school_name': 'school_name',
|
|
||||||
'local_authority': 'local_authority',
|
|
||||||
'local_authority_code': 'local_authority_code',
|
|
||||||
'school_type': 'school_type',
|
|
||||||
'school_type_code': 'school_type_code',
|
|
||||||
'religious_denomination': 'religious_denomination',
|
|
||||||
'age_range': 'age_range',
|
|
||||||
'address1': 'address1',
|
|
||||||
'address2': 'address2',
|
|
||||||
'town': 'town',
|
|
||||||
'postcode': 'postcode',
|
|
||||||
}
|
|
||||||
|
|
||||||
RESULT_FIELD_MAPPING = {
|
|
||||||
'year': 'year',
|
|
||||||
'total_pupils': 'total_pupils',
|
|
||||||
'eligible_pupils': 'eligible_pupils',
|
|
||||||
# Expected Standard
|
|
||||||
'rwm_expected_pct': 'rwm_expected_pct',
|
|
||||||
'reading_expected_pct': 'reading_expected_pct',
|
|
||||||
'writing_expected_pct': 'writing_expected_pct',
|
|
||||||
'maths_expected_pct': 'maths_expected_pct',
|
|
||||||
'gps_expected_pct': 'gps_expected_pct',
|
|
||||||
'science_expected_pct': 'science_expected_pct',
|
|
||||||
# Higher Standard
|
|
||||||
'rwm_high_pct': 'rwm_high_pct',
|
|
||||||
'reading_high_pct': 'reading_high_pct',
|
|
||||||
'writing_high_pct': 'writing_high_pct',
|
|
||||||
'maths_high_pct': 'maths_high_pct',
|
|
||||||
'gps_high_pct': 'gps_high_pct',
|
|
||||||
# Progress
|
|
||||||
'reading_progress': 'reading_progress',
|
|
||||||
'writing_progress': 'writing_progress',
|
|
||||||
'maths_progress': 'maths_progress',
|
|
||||||
# Averages
|
|
||||||
'reading_avg_score': 'reading_avg_score',
|
|
||||||
'maths_avg_score': 'maths_avg_score',
|
|
||||||
'gps_avg_score': 'gps_avg_score',
|
|
||||||
# Context
|
|
||||||
'disadvantaged_pct': 'disadvantaged_pct',
|
|
||||||
'eal_pct': 'eal_pct',
|
|
||||||
'sen_support_pct': 'sen_support_pct',
|
|
||||||
'sen_ehcp_pct': 'sen_ehcp_pct',
|
|
||||||
'stability_pct': 'stability_pct',
|
|
||||||
# Absence
|
|
||||||
'reading_absence_pct': 'reading_absence_pct',
|
|
||||||
'gps_absence_pct': 'gps_absence_pct',
|
|
||||||
'maths_absence_pct': 'maths_absence_pct',
|
|
||||||
'writing_absence_pct': 'writing_absence_pct',
|
|
||||||
'science_absence_pct': 'science_absence_pct',
|
|
||||||
# Gender
|
|
||||||
'rwm_expected_boys_pct': 'rwm_expected_boys_pct',
|
|
||||||
'rwm_expected_girls_pct': 'rwm_expected_girls_pct',
|
|
||||||
'rwm_high_boys_pct': 'rwm_high_boys_pct',
|
|
||||||
'rwm_high_girls_pct': 'rwm_high_girls_pct',
|
|
||||||
# Disadvantaged
|
|
||||||
'rwm_expected_disadvantaged_pct': 'rwm_expected_disadvantaged_pct',
|
|
||||||
'rwm_expected_non_disadvantaged_pct': 'rwm_expected_non_disadvantaged_pct',
|
|
||||||
'disadvantaged_gap': 'disadvantaged_gap',
|
|
||||||
# 3-Year
|
|
||||||
'rwm_expected_3yr_pct': 'rwm_expected_3yr_pct',
|
|
||||||
'reading_avg_3yr': 'reading_avg_3yr',
|
|
||||||
'maths_avg_3yr': 'maths_avg_3yr',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
rwm_expected_pct = Column(Float)
|
||||||
|
rwm_high_pct = Column(Float)
|
||||||
|
reading_expected_pct = Column(Float)
|
||||||
|
reading_high_pct = Column(Float)
|
||||||
|
reading_avg_score = Column(Float)
|
||||||
|
writing_expected_pct = Column(Float)
|
||||||
|
writing_gd_pct = Column(Float)
|
||||||
|
maths_expected_pct = Column(Float)
|
||||||
|
maths_high_pct = Column(Float)
|
||||||
|
maths_avg_score = Column(Float)
|
||||||
|
gps_expected_pct = Column(Float)
|
||||||
|
gps_high_pct = Column(Float)
|
||||||
|
gps_avg_score = Column(Float)
|
||||||
|
science_expected_pct = Column(Float)
|
||||||
|
|||||||
+89
-10
@@ -142,7 +142,7 @@ NULL_VALUES = ["SUPP", "NE", "NA", "NP", "NEW", "LOW", ""]
|
|||||||
METRIC_DEFINITIONS = {
|
METRIC_DEFINITIONS = {
|
||||||
# Expected Standard
|
# Expected Standard
|
||||||
"rwm_expected_pct": {
|
"rwm_expected_pct": {
|
||||||
"name": "RWM Combined %",
|
"name": "Reading, Writing & Maths Combined %",
|
||||||
"short_name": "RWM %",
|
"short_name": "RWM %",
|
||||||
"description": "% meeting expected standard in reading, writing and maths",
|
"description": "% meeting expected standard in reading, writing and maths",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -185,9 +185,9 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Higher Standard
|
# Higher Standard
|
||||||
"rwm_high_pct": {
|
"rwm_high_pct": {
|
||||||
"name": "RWM Combined Higher %",
|
"name": "Reading, Writing & Maths Combined Higher %",
|
||||||
"short_name": "RWM Higher %",
|
"short_name": "RWM Higher %",
|
||||||
"description": "% achieving higher standard in RWM combined",
|
"description": "% achieving higher standard in reading, writing & maths combined",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "higher",
|
"category": "higher",
|
||||||
},
|
},
|
||||||
@@ -265,28 +265,28 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Gender Performance
|
# Gender Performance
|
||||||
"rwm_expected_boys_pct": {
|
"rwm_expected_boys_pct": {
|
||||||
"name": "RWM Expected % (Boys)",
|
"name": "Reading, Writing & Maths Expected % (Boys)",
|
||||||
"short_name": "Boys RWM %",
|
"short_name": "Boys RWM %",
|
||||||
"description": "% of boys meeting expected standard",
|
"description": "% of boys meeting expected standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_expected_girls_pct": {
|
"rwm_expected_girls_pct": {
|
||||||
"name": "RWM Expected % (Girls)",
|
"name": "Reading, Writing & Maths Expected % (Girls)",
|
||||||
"short_name": "Girls RWM %",
|
"short_name": "Girls RWM %",
|
||||||
"description": "% of girls meeting expected standard",
|
"description": "% of girls meeting expected standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_high_boys_pct": {
|
"rwm_high_boys_pct": {
|
||||||
"name": "RWM Higher % (Boys)",
|
"name": "Reading, Writing & Maths Higher % (Boys)",
|
||||||
"short_name": "Boys Higher %",
|
"short_name": "Boys Higher %",
|
||||||
"description": "% of boys at higher standard",
|
"description": "% of boys at higher standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_high_girls_pct": {
|
"rwm_high_girls_pct": {
|
||||||
"name": "RWM Higher % (Girls)",
|
"name": "Reading, Writing & Maths Higher % (Girls)",
|
||||||
"short_name": "Girls Higher %",
|
"short_name": "Girls Higher %",
|
||||||
"description": "% of girls at higher standard",
|
"description": "% of girls at higher standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -294,14 +294,14 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Disadvantaged Performance
|
# Disadvantaged Performance
|
||||||
"rwm_expected_disadvantaged_pct": {
|
"rwm_expected_disadvantaged_pct": {
|
||||||
"name": "RWM Expected % (Disadvantaged)",
|
"name": "Reading, Writing & Maths Expected % (Disadvantaged)",
|
||||||
"short_name": "Disadvantaged %",
|
"short_name": "Disadvantaged %",
|
||||||
"description": "% of disadvantaged pupils meeting expected",
|
"description": "% of disadvantaged pupils meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "equity",
|
"category": "equity",
|
||||||
},
|
},
|
||||||
"rwm_expected_non_disadvantaged_pct": {
|
"rwm_expected_non_disadvantaged_pct": {
|
||||||
"name": "RWM Expected % (Non-Disadvantaged)",
|
"name": "Reading, Writing & Maths Expected % (Non-Disadvantaged)",
|
||||||
"short_name": "Non-Disadv %",
|
"short_name": "Non-Disadv %",
|
||||||
"description": "% of non-disadvantaged pupils meeting expected",
|
"description": "% of non-disadvantaged pupils meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -381,7 +381,7 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# 3-Year Averages
|
# 3-Year Averages
|
||||||
"rwm_expected_3yr_pct": {
|
"rwm_expected_3yr_pct": {
|
||||||
"name": "RWM Expected % (3-Year Avg)",
|
"name": "Reading, Writing & Maths Expected % (3-Year Avg)",
|
||||||
"short_name": "RWM 3yr %",
|
"short_name": "RWM 3yr %",
|
||||||
"description": "3-year average % meeting expected",
|
"description": "3-year average % meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -401,6 +401,70 @@ METRIC_DEFINITIONS = {
|
|||||||
"type": "score",
|
"type": "score",
|
||||||
"category": "trends",
|
"category": "trends",
|
||||||
},
|
},
|
||||||
|
# ── GCSE Performance (KS4) ────────────────────────────────────────────
|
||||||
|
"attainment_8_score": {
|
||||||
|
"name": "Attainment 8",
|
||||||
|
"short_name": "Att 8",
|
||||||
|
"description": "Average grade across a pupil's best 8 GCSEs including English and Maths",
|
||||||
|
"type": "score",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"progress_8_score": {
|
||||||
|
"name": "Progress 8",
|
||||||
|
"short_name": "P8",
|
||||||
|
"description": "Progress from KS2 baseline to GCSE relative to similar pupils nationally (0 = national average)",
|
||||||
|
"type": "score",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"english_maths_standard_pass_pct": {
|
||||||
|
"name": "English & Maths Grade 4+",
|
||||||
|
"short_name": "E&M 4+",
|
||||||
|
"description": "% of pupils achieving grade 4 (standard pass) or above in both English and Maths",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"english_maths_strong_pass_pct": {
|
||||||
|
"name": "English & Maths Grade 5+",
|
||||||
|
"short_name": "E&M 5+",
|
||||||
|
"description": "% of pupils achieving grade 5 (strong pass) or above in both English and Maths",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"ebacc_entry_pct": {
|
||||||
|
"name": "EBacc Entry %",
|
||||||
|
"short_name": "EBacc Entry",
|
||||||
|
"description": "% of pupils entered for the English Baccalaureate (English, Maths, Sciences, Languages, Humanities)",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"ebacc_standard_pass_pct": {
|
||||||
|
"name": "EBacc Grade 4+",
|
||||||
|
"short_name": "EBacc 4+",
|
||||||
|
"description": "% of pupils achieving grade 4+ across all EBacc subjects",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"ebacc_strong_pass_pct": {
|
||||||
|
"name": "EBacc Grade 5+",
|
||||||
|
"short_name": "EBacc 5+",
|
||||||
|
"description": "% of pupils achieving grade 5+ across all EBacc subjects",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"ebacc_avg_score": {
|
||||||
|
"name": "EBacc Average Score",
|
||||||
|
"short_name": "EBacc Avg",
|
||||||
|
"description": "Average points score across EBacc subjects",
|
||||||
|
"type": "score",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
|
"gcse_grade_91_pct": {
|
||||||
|
"name": "GCSE Grade 9–1 %",
|
||||||
|
"short_name": "GCSE 9–1",
|
||||||
|
"description": "% of GCSE entries achieving a grade 9 to 1",
|
||||||
|
"type": "percentage",
|
||||||
|
"category": "gcse",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ranking columns to include in rankings response
|
# Ranking columns to include in rankings response
|
||||||
@@ -456,6 +520,16 @@ RANKING_COLUMNS = [
|
|||||||
"rwm_expected_3yr_pct",
|
"rwm_expected_3yr_pct",
|
||||||
"reading_avg_3yr",
|
"reading_avg_3yr",
|
||||||
"maths_avg_3yr",
|
"maths_avg_3yr",
|
||||||
|
# GCSE (KS4)
|
||||||
|
"attainment_8_score",
|
||||||
|
"progress_8_score",
|
||||||
|
"english_maths_standard_pass_pct",
|
||||||
|
"english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entry_pct",
|
||||||
|
"ebacc_standard_pass_pct",
|
||||||
|
"ebacc_strong_pass_pct",
|
||||||
|
"ebacc_avg_score",
|
||||||
|
"gcse_grade_91_pct",
|
||||||
]
|
]
|
||||||
|
|
||||||
# School listing columns
|
# School listing columns
|
||||||
@@ -469,6 +543,11 @@ SCHOOL_COLUMNS = [
|
|||||||
"postcode",
|
"postcode",
|
||||||
"religious_denomination",
|
"religious_denomination",
|
||||||
"age_range",
|
"age_range",
|
||||||
|
"gender",
|
||||||
|
"admissions_policy",
|
||||||
|
"ofsted_grade",
|
||||||
|
"ofsted_date",
|
||||||
|
"ofsted_framework",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,7 @@ WHEN TO BUMP:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Current schema version - increment when models change
|
# Current schema version - increment when models change
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 6
|
||||||
|
|
||||||
# Changelog for documentation
|
# Changelog for documentation
|
||||||
SCHEMA_CHANGELOG = {
|
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",
|
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
|
||||||
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
|
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
|
||||||
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
|
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
|
||||||
|
6: "Removed the Ofsted Parent View feature: dropped fact_parent_view table and model",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,26 @@ This starts:
|
|||||||
- `GET /api/metrics` - Metric definitions (single source of truth)
|
- `GET /api/metrics` - Metric definitions (single source of truth)
|
||||||
- `GET /api/data-info` - Database stats
|
- `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
|
## Recent Changes
|
||||||
|
|
||||||
|
- Added staging environment + automated staging→prod pipeline (Gitea Actions)
|
||||||
- Migrated from CSV file storage to PostgreSQL database
|
- Migrated from CSV file storage to PostgreSQL database
|
||||||
- Added location-based search using postcode geocoding
|
- Added location-based search using postcode geocoding
|
||||||
- Added local authority filter to rankings
|
- Added local authority filter to rankings
|
||||||
- Improved frontend with featured schools, loading states, API caching
|
- Improved frontend with featured schools, loading states, API caching
|
||||||
|
|
||||||
|
# Important
|
||||||
|
- Do not attempt to start a local server to test the application, it does not work
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
# Place your CSV data files here
|
|
||||||
# Download from: https://www.compare-school-performance.service.gov.uk/download-data
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,24 +0,0 @@
|
|||||||
Field Number,Field Reference,Field Name,Values,Data Format,LA level field?,National level field?
|
|
||||||
1,URN,School Unique Reference Number,999999,I6,No,No
|
|
||||||
2,LA,LA number,999,I3,Yes,No
|
|
||||||
3,ESTAB,ESTAB number,9999,I4,No,No
|
|
||||||
4,SCHOOLTYPE,Type of school,String,,No,No
|
|
||||||
5,NOR,Total number of pupils on roll,9999 or NA,,Yes,Yes
|
|
||||||
6,NORG,Number of girls on roll,9999 or NA,,Yes,Yes
|
|
||||||
7,NORB,Number of boys on roll,9999 or NA,,Yes,Yes
|
|
||||||
8,PNORG,Percentage of girls on roll,99.9 or NA,,Yes,Yes
|
|
||||||
9,PNORB,Percentage of boys on roll,99.9 or NA,,Yes,Yes
|
|
||||||
10,TSENELSE,Number of eligible pupils with an EHC plan,9999 or NA,A4,Yes,Yes
|
|
||||||
11,PSENELSE,Percentage of eligible pupils with an EHC plan,99.9 or NA,A4,Yes,Yes
|
|
||||||
12,TSENELK,Number of eligible pupils with SEN support,9999 or NA,A4,Yes,Yes
|
|
||||||
13,PSENELK,Percentage of eligible pupils with SEN support,99.9 or NA,A4,Yes,Yes
|
|
||||||
14,NUMEAL,No. pupils where English not first language,9999 or NA,A4,Yes,Yes
|
|
||||||
15,NUMENGFL,No. pupils with English first language,9999 or NA,A4,Yes,Yes
|
|
||||||
16,NUMUNCFL,No. pupils where first language is unclassified,9999 or NA,A4,Yes,Yes
|
|
||||||
17,PNUMEAL,% pupils where English not first language,99.9 or NA,A4,Yes,Yes
|
|
||||||
18,PNUMENGFL,% pupils with English first language,99.9 or NA,A4,Yes,Yes
|
|
||||||
19,PNUMUNCFL,% pupils where first language is unclassified,99.9 or NA,A4,Yes,Yes
|
|
||||||
20,NUMFSM,No. pupils eligible for free school meals,9999 or NA,A4,Yes,Yes
|
|
||||||
21,NUMFSMEVER,Number of pupils eligible for FSM at any time during the past 6 years,9999 or NA,A6,Yes,Yes
|
|
||||||
22,NORFSMEVER,Total pupils for FSMEver,9999 or NA,,Yes,Yes
|
|
||||||
23,PNUMFSMEVER,Percentage of pupils eligible for FSM at any time during the past 6 years,99.9 or NA,A4,Yes,Yes
|
|
||||||
|
@@ -1,312 +0,0 @@
|
|||||||
Column,Field Name,Label/Description
|
|
||||||
1,RECTYPE,Record type
|
|
||||||
2,AlphaIND,Alphabetic index
|
|
||||||
3,LEA,Local authority number
|
|
||||||
4,ESTAB,Establishment number
|
|
||||||
5,URN,School unique reference number
|
|
||||||
6,SCHNAME,School/Local authority name
|
|
||||||
7,ADDRESS1,School address (1)
|
|
||||||
8,ADDRESS2,School address (2)
|
|
||||||
9,ADDRESS3,School address (3)
|
|
||||||
10,TOWN,School town
|
|
||||||
11,PCODE,School postcode
|
|
||||||
12,TELNUM,School telephone number
|
|
||||||
13,PCON_CODE,School parliamentary constituency code
|
|
||||||
14,PCON_NAME,School parliamentary constituency name
|
|
||||||
15,URN_AC,Converter academy: URN
|
|
||||||
16,SCHNAME_AC,Converter academy: name
|
|
||||||
17,OPEN_AC,Converter academy: open date
|
|
||||||
18,NFTYPE,School type
|
|
||||||
19,ICLOSE,Closed Flag
|
|
||||||
20,RELDENOM,Religious denomination
|
|
||||||
21,AGERANGE,Age range
|
|
||||||
22,TAB15,School published in secondary school (key stage 4) performance tables
|
|
||||||
23,TAB1618,School published in school and college (key stage 5) performance tables
|
|
||||||
24,TOTPUPS,Total number of pupils (including part-time pupils)
|
|
||||||
25,TPUPYEAR,Number of pupils aged 11
|
|
||||||
26,TELIG,Published eligible pupil number
|
|
||||||
27,BELIG,Eligible boys on school roll at time of tests
|
|
||||||
28,GELIG,Eligible girls on school roll at time of tests
|
|
||||||
29,PBELIG,Percentage of eligible boys on school roll at time of tests
|
|
||||||
30,PGELIG,Percentage of eligible girls on school roll at time of tests
|
|
||||||
31,TKS1AVERAGE,Cohort level key stage 1 average points score [not populated in 2025]
|
|
||||||
32,TKS1GROUP_L,Number of pupils in cohort with low KS1 attainment [not populated in 2025]
|
|
||||||
33,PTKS1GROUP_L,Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
|
|
||||||
34,TKS1GROUP_M,Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
|
||||||
35,PTKS1GROUP_M,Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
|
||||||
36,TKS1GROUP_H,Number of pupils in cohort high KS1 attainment [not populated in 2025]
|
|
||||||
37,PTKS1GROUP_H,Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
|
|
||||||
38,TKS1GROUP_NA,No. of pupils in KS1 group not calculable [not populated in 2025]
|
|
||||||
39,PTKS1GROUP_NA,Percentage of pupils in KS1group not calculable [not populated in 2025]
|
|
||||||
40,TFSM6CLA1A,Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
|
|
||||||
41,PTFSM6CLA1A,Percentage of key stage 2 disadvantaged pupils
|
|
||||||
42,TNotFSM6CLA1A,Number of key stage 2 pupils who are not disadvantaged
|
|
||||||
43,PTNotFSM6CLA1A,Percentage of key stage 2 pupils who are not disadvantaged
|
|
||||||
44,TEALGRP2,Number of eligible pupils with English as additional language (EAL)
|
|
||||||
45,PTEALGRP2,Percentage of eligible pupils with English as additional language (EAL)
|
|
||||||
46,TMOBN,Number of eligible pupils classified as non-mobile
|
|
||||||
47,PTMOBN,Percentage of eligible pupils classified as non-mobile
|
|
||||||
48,PTRWM_EXP,"Percentage of pupils reaching the expected standard in reading, writing and maths"
|
|
||||||
49,PTRWM_HIGH,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
50,READPROG,Reading progress measure [not populated in 2025]
|
|
||||||
51,READPROG_LOWER,Reading progress measure - lower confidence limit [not populated in 2025]
|
|
||||||
52,READPROG_UPPER,Reading progress measure - upper confidence limit [not populated in 2025]
|
|
||||||
53,READCOV,Reading progress measure - coverage [not populated in 2025]
|
|
||||||
54,WRITPROG,Writing progress measure [not populated in 2025]
|
|
||||||
55,WRITPROG_LOWER,Writing progress measure - lower confidence limit [not populated in 2025]
|
|
||||||
56,WRITPROG_UPPER,Writing progress measure - upper confidence limit [not populated in 2025]
|
|
||||||
57,WRITCOV,Writing progress measure - coverage [not populated in 2025]
|
|
||||||
58,MATPROG,Maths progress measure [not populated in 2025]
|
|
||||||
59,MATPROG_LOWER,Maths progress measure - lower confidence limit [not populated in 2025]
|
|
||||||
60,MATPROG_UPPER,Maths progress measure - upper confidence limit [not populated in 2025]
|
|
||||||
61,MATCOV,Maths progress measure - coverage [not populated in 2025]
|
|
||||||
62,PTREAD_EXP,Percentage of pupils reaching the expected standard in reading
|
|
||||||
63,PTREAD_HIGH,Percentage of pupils achieving a high score in reading
|
|
||||||
64,PTREAD_AT,Percentage of pupils absent from or not able to access the test in reading
|
|
||||||
65,READ_AVERAGE,Average scaled score in reading
|
|
||||||
66,PTGPS_EXP,"Percentage of pupils reaching the expected standard in grammar, punctuation and spelling"
|
|
||||||
67,PTGPS_HIGH,"Percentage of pupils achieving a high score in grammar, punctuation and spelling"
|
|
||||||
68,PTGPS_AT,"Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling"
|
|
||||||
69,GPS_AVERAGE,"Average scaled score in grammar, punctuation and spelling"
|
|
||||||
70,PTMAT_EXP,Percentage of pupils reaching the expected standard in maths
|
|
||||||
71,PTMAT_HIGH,Percentage of pupils achieving a high score in maths
|
|
||||||
72,PTMAT_AT,Percentage of pupils absent from or not able to access the test in maths
|
|
||||||
73,MAT_AVERAGE,Average scaled score in maths
|
|
||||||
74,PTWRITTA_EXP,Percentage of pupils reaching the expected standard in writing
|
|
||||||
75,PTWRITTA_HIGH,Percentage of pupils working at greater depth within the expected standard in writing
|
|
||||||
76,PTWRITTA_WTS,Percentage of pupils working towards the expected standard in writing
|
|
||||||
77,PTWRITTA_AD,Percentage of pupils absent or disapplied in writing TA
|
|
||||||
78,PTSCITA_EXP,Percentage of pupils reaching the expected standard in science TA
|
|
||||||
79,PTSCITA_AD,Percentage of pupils absent or disapplied in science TA
|
|
||||||
80,PTRWM_EXP_B,"Percentage of boys reaching the expected standard in reading, writing and maths"
|
|
||||||
81,PTRWM_EXP_G,"Percentage of girls reaching the expected standard in reading, writing and maths"
|
|
||||||
82,PTRWM_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
|
||||||
83,PTRWM_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
|
||||||
84,PTRWM_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
|
||||||
85,PTRWM_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
|
||||||
86,PTRWM_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
|
||||||
87,DIFFN_RWM_EXP,"Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths "
|
|
||||||
88,PTRWM_EXP_EAL,"Percentage of EAL pupils reaching the expected standard in reading, writing and maths"
|
|
||||||
89,PTRWM_EXP_MOBN,"Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths"
|
|
||||||
90,PTRWM_HIGH_B,Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
91,PTRWM_HIGH_G,"Percentage of girls reaching the HIGHected standard in reading, writing and maths"
|
|
||||||
92,PTRWM_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
|
||||||
93,PTRWM_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
|
||||||
94,PTRWM_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
|
||||||
95,PTRWM_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
96,PTRWM_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
97,DIFFN_RWM_HIGH,"Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths "
|
|
||||||
98,PTRWM_HIGH_EAL,Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
99,PTRWM_HIGH_MOBN,Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
|
|
||||||
100,READPROG_B,Reading progress measure for boys [not populated in 2025]
|
|
||||||
101,READPROG_B_LOWER,Reading progress measure for boys - lower confidence limit [not populated in 2025]
|
|
||||||
102,READPROG_B_UPPER,Reading progress measure for boys - upper confidence limit [not populated in 2025]
|
|
||||||
103,READPROG_G,Reading progress measure for girls [not populated in 2025]
|
|
||||||
104,READPROG_G_LOWER,Reading progress measure for girls - lower confidence limit [not populated in 2025]
|
|
||||||
105,READPROG_G_UPPER,Reading progress measure for girls - upper confidence limit [not populated in 2025]
|
|
||||||
106,READPROG_L,Reading progress measure for pupils with low prior attainment [not populated in 2025]
|
|
||||||
107,READPROG_L_LOWER,Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
108,READPROG_L_UPPER,Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
109,READPROG_M,Reading progress measure for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
110,READPROG_M_LOWER,Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
111,READPROG_M_UPPER,Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
112,READPROG_H,Reading progress measure for pupils with high prior attainment [not populated in 2025]
|
|
||||||
113,READPROG_H_LOWER,Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
114,READPROG_H_UPPER,Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
115,READPROG_FSM6CLA1A,Reading progress measure for disadvantaged pupils [not populated in 2025]
|
|
||||||
116,READPROG_FSM6CLA1A_LOWER,Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
117,READPROG_FSM6CLA1A_UPPER,Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
118,READPROG_NotFSM6CLA1A,Reading progress measure for non-disadvantaged pupils [not populated in 2025]
|
|
||||||
119,READPROG_NotFSM6CLA1A_LOWER,Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
120,READPROG_NotFSM6CLA1A_UPPER,Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
121,DIFFN_READPROG,Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
|
||||||
122,READPROG_EAL,Reading progress measure for EAL pupils [not populated in 2025]
|
|
||||||
123,READPROG_EAL_LOWER,Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
|
||||||
124,READPROG_EAL_UPPER,Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
|
||||||
125,READPROG_MOBN,Reading progress measure for non-mobile pupils [not populated in 2025]
|
|
||||||
126,READPROG_MOBN_LOWER,Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
|
||||||
127,READPROG_MOBN_UPPER,Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
|
||||||
128,WRITPROG_B,Writing progress measure for boys [not populated in 2025]
|
|
||||||
129,WRITPROG_B_LOWER,Writing progress measure for boys - lower confidence limit [not populated in 2025]
|
|
||||||
130,WRITPROG_B_UPPER,Writing progress measure for boys - upper confidence limit [not populated in 2025]
|
|
||||||
131,WRITPROG_G,Writing progress measure for girls [not populated in 2025]
|
|
||||||
132,WRITPROG_G_LOWER,Writing progress measure for girls - lower confidence limit [not populated in 2025]
|
|
||||||
133,WRITPROG_G_UPPER,Writing progress measure for girls - upper confidence limit [not populated in 2025]
|
|
||||||
134,WRITPROG_L,Writing progress measure for pupils with low prior attainment [not populated in 2025]
|
|
||||||
135,WRITPROG_L_LOWER,Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
136,WRITPROG_L_UPPER,Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
137,WRITPROG_M,Writing progress measure for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
138,WRITPROG_M_LOWER,Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
139,WRITPROG_M_UPPER,Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
140,WRITPROG_H,Writing progress measure for pupils with high prior attainment [not populated in 2025]
|
|
||||||
141,WRITPROG_H_LOWER,Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
142,WRITPROG_H_UPPER,Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
143,WRITPROG_FSM6CLA1A,Writing progress measure for disadvantaged pupils [not populated in 2025]
|
|
||||||
144,WRITPROG_FSM6CLA1A_LOWER,Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
145,WRITPROG_FSM6CLA1A_UPPER,Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
146,WRITPROG_NotFSM6CLA1A,Writing progress measure for non-disadvantaged pupils [not populated in 2025]
|
|
||||||
147,WRITPROG_NotFSM6CLA1A_LOWER,Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
148,WRITPROG_NotFSM6CLA1A_UPPER,Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
149,DIFFN_WRITPROG,Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
|
||||||
150,WRITPROG_EAL,Writing progress measure for EAL pupils [not populated in 2025]
|
|
||||||
151,WRITPROG_EAL_LOWER,Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
|
||||||
152,WRITPROG_EAL_UPPER,Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
|
||||||
153,WRITPROG_MOBN,Writing progress measure for non-mobile pupils [not populated in 2025]
|
|
||||||
154,WRITPROG_MOBN_LOWER,Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
|
||||||
155,WRITPROG_MOBN_UPPER,Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
|
||||||
156,MATPROG_B,Maths progress measure for boys [not populated in 2025]
|
|
||||||
157,MATPROG_B_LOWER,Maths progress measure for boys - lower confidence limit [not populated in 2025]
|
|
||||||
158,MATPROG_B_UPPER,Maths progress measure for boys - upper confidence limit [not populated in 2025]
|
|
||||||
159,MATPROG_G,Maths progress measure for girls [not populated in 2025]
|
|
||||||
160,MATPROG_G_LOWER,Maths progress measure for girls - lower confidence limit [not populated in 2025]
|
|
||||||
161,MATPROG_G_UPPER,Maths progress measure for girls - upper confidence limit [not populated in 2025]
|
|
||||||
162,MATPROG_L,Maths progress measure for pupils with low prior attainment [not populated in 2025]
|
|
||||||
163,MATPROG_L_LOWER,Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
164,MATPROG_L_UPPER,Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
165,MATPROG_M,Maths progress measure for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
166,MATPROG_M_LOWER,Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
167,MATPROG_M_UPPER,Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
168,MATPROG_H,Maths progress measure for pupils with high prior attainment [not populated in 2025]
|
|
||||||
169,MATPROG_H_LOWER,Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
|
||||||
170,MATPROG_H_UPPER,Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
|
||||||
171,MATPROG_FSM6CLA1A,Maths progress measure for disadvantaged pupils [not populated in 2025]
|
|
||||||
172,MATPROG_FSM6CLA1A_LOWER,Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
173,MATPROG_FSM6CLA1A_UPPER,Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
174,MATPROG_NotFSM6CLA1A,Maths progress measure for non-disadvantaged pupils [not populated in 2025]
|
|
||||||
175,MATPROG_NotFSM6CLA1A_LOWER,Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
|
||||||
176,MATPROG_NotFSM6CLA1A_UPPER,Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
|
||||||
177,DIFFN_MATPROG,Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
|
||||||
178,MATPROG_EAL,Maths progress measure for EAL pupils [not populated in 2025]
|
|
||||||
179,MATPROG_EAL_LOWER,Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
|
||||||
180,MATPROG_EAL_UPPER,Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
|
||||||
181,MATPROG_MOBN,Maths progress measure for non-mobile pupils [not populated in 2025]
|
|
||||||
182,MATPROG_MOBN_LOWER,Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
|
||||||
183,MATPROG_MOBN_UPPER,Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
|
||||||
184,READ_AVERAGE_B,Average scaled score in reading for boys
|
|
||||||
185,READ_AVERAGE_G,Average scaled score in reading for girls
|
|
||||||
186,READ_AVERAGE_L,Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
|
|
||||||
187,READ_AVERAGE_M,Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
188,READ_AVERAGE_H,Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
|
|
||||||
189,READ_AVERAGE_FSM6CLA1A,Average scaled score in reading for disadvantaged pupils
|
|
||||||
190,READ_AVERAGE_NotFSM6CLA1A,Average scaled score in reading for non-disadvantaged pupils
|
|
||||||
191,READ_AVERAGE_EAL,Average scaled score in reading for EAL pupils
|
|
||||||
192,READ_AVERAGE_MOBN,Average scaled score in reading for MOBN pupils
|
|
||||||
193,MAT_AVERAGE_B,Average scaled score in maths for boys
|
|
||||||
194,MAT_AVERAGE_G,Average scaled score in maths for girls
|
|
||||||
195,MAT_AVERAGE_L,Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
|
|
||||||
196,MAT_AVERAGE_M,Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
197,MAT_AVERAGE_H,Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
|
|
||||||
198,MAT_AVERAGE_FSM6CLA1A,Average scaled score in maths for disadvantaged pupils
|
|
||||||
199,MAT_AVERAGE_NotFSM6CLA1A,Average scaled score in maths for non-disadvantaged pupils
|
|
||||||
200,MAT_AVERAGE_EAL,Average scaled score in maths for EAL pupils
|
|
||||||
201,MAT_AVERAGE_MOBN,Average scaled score in maths for MOBN pupils
|
|
||||||
202,GPS_AVERAGE_B,Average scaled score in GPS for boys
|
|
||||||
203,GPS_AVERAGE_G,Average scaled score in GPS for girls
|
|
||||||
204,GPS_AVERAGE_L,Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
|
|
||||||
205,GPS_AVERAGE_M,Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
|
|
||||||
206,GPS_AVERAGE_H,Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
|
|
||||||
207,GPS_AVERAGE_FSM6CLA1A,Average scaled score in GPS for disadvantaged pupils
|
|
||||||
208,GPS_AVERAGE_NotFSM6CLA1A,Average scaled score in GPS for non-disadvantaged pupils
|
|
||||||
209,GPS_AVERAGE_EAL,Average scaled score in GPS for EAL pupils
|
|
||||||
210,GPS_AVERAGE_MOBN,Average scaled score in GPS for MOBN pupils
|
|
||||||
211,PTREAD_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
|
|
||||||
212,PTREAD_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
|
|
||||||
213,PTREAD_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
|
|
||||||
214,PTREAD_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in reading
|
|
||||||
215,PTREAD_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in reading
|
|
||||||
216,PTGPS_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
217,PTGPS_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
218,PTGPS_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
219,PTGPS_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
|
||||||
220,PTGPS_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
|
||||||
221,PTMAT_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
|
|
||||||
222,PTMAT_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
|
|
||||||
223,PTMAT_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
|
|
||||||
224,PTMAT_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in maths
|
|
||||||
225,PTMAT_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in maths
|
|
||||||
226,PTWRITTA_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
|
|
||||||
227,PTWRITTA_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
|
|
||||||
228,PTWRITTA_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
|
|
||||||
229,PTWRITTA_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in writing
|
|
||||||
230,PTWRITTA_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in writing
|
|
||||||
231,PTREAD_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
|
|
||||||
232,PTREAD_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
|
|
||||||
233,PTREAD_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
|
|
||||||
234,PTREAD_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading
|
|
||||||
235,PTREAD_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading
|
|
||||||
236,PTGPS_HIGH_L,"Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
237,PTGPS_HIGH_M,"Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
238,PTGPS_HIGH_H,"Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
|
||||||
239,PTGPS_HIGH_FSM6CLA1A,"Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
|
||||||
240,PTGPS_HIGH_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
|
||||||
241,PTMAT_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
|
|
||||||
242,PTMAT_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
|
|
||||||
243,PTMAT_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
|
|
||||||
244,PTMAT_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in maths
|
|
||||||
245,PTMAT_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in maths
|
|
||||||
246,PTWRITTA_HIGH_L,Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
|
|
||||||
247,PTWRITTA_HIGH_M,Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
|
|
||||||
248,PTWRITTA_HIGH_H,Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
|
|
||||||
249,PTWRITTA_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils working at greater depth in writing
|
|
||||||
250,PTWRITTA_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils working at greater depth in writing
|
|
||||||
251,TEALGRP1,Number of eligible pupils with English as first language
|
|
||||||
252,PTEALGRP1,Percentage of eligible pupils with English as first language
|
|
||||||
253,TEALGRP3,Number of eligible pupils with unclassified language
|
|
||||||
254,PTEALGRP3,Percentage of eligible pupils with unclassified language
|
|
||||||
255,TSENELE,Number of eligible pupils with EHC plan
|
|
||||||
256,PSENELE,Percentage of eligible pupils with EHC plan
|
|
||||||
257,TSENELK,Number of eligible pupils with SEN support
|
|
||||||
258,PSENELK,Percentage of eligible pupils with SEN support
|
|
||||||
259,TSENELEK,Number of eligible pupils with SEN (EHC plan or SEN support)
|
|
||||||
260,PSENELEK,Percentage of eligible pupils with SEN (EHC plan or SEN support)
|
|
||||||
261,TELIG_24,Number of eligible pupils 2024
|
|
||||||
262,PTFSM6CLA1A_24,Percentage of key stage 2 disadvantaged pupils one year prior
|
|
||||||
263,PTNOTFSM6CLA1A_24,Percentage of key stage 2 pupils who are not disadvantaged one year prior
|
|
||||||
264,PTRWM_EXP_24,"Percentage of pupils reaching the expected standard in reading, writing and maths one year prior"
|
|
||||||
265,PTRWM_HIGH_24,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
|
||||||
266,PTRWM_EXP_FSM6CLA1A_24,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
|
||||||
267,PTRWM_HIGH_FSM6CLA1A_24,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
|
||||||
268,PTRWM_EXP_NotFSM6CLA1A_24,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
|
||||||
269,PTRWM_HIGH_NotFSM6CLA1A_24,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
|
||||||
270,READPROG_24,Reading progress measure - one year prior [not populated in 2025]
|
|
||||||
271,READPROG_LOWER_24,Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
|
|
||||||
272,READPROG_UPPER_24,Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
|
|
||||||
273,WRITPROG_24,Writing progress measure - one year prior [not populated in 2025]
|
|
||||||
274,WRITPROG_LOWER_24,Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
|
|
||||||
275,WRITPROG_UPPER_24,Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
|
|
||||||
276,MATPROG_24,Maths progress measure - one year prior [not populated in 2025]
|
|
||||||
277,MATPROG_LOWER_24,Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
|
|
||||||
278,MATPROG_UPPER_24,Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
|
|
||||||
279,READ_AVERAGE_24,Average scaled score in reading - one year prior
|
|
||||||
280,MAT_AVERAGE_24,Average scaled score in maths - one year prior
|
|
||||||
281,TELIG_23,Number of eligible pupils 2023
|
|
||||||
282,PTFSM6CLA1A_23,Percentage of key stage 2 disadvantaged pupils - two years prior
|
|
||||||
283,PTNOTFSM6CLA1A_23,Percentage of key stage 2 pupils who are not disadvantaged - two years prior
|
|
||||||
284,PTRWM_EXP_23,"Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior"
|
|
||||||
285,PTRWM_HIGH_23,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
|
||||||
286,PTRWM_EXP_FSM6CLA1A_23,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
|
||||||
287,PTRWM_HIGH_FSM6CLA1A_23,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
|
||||||
288,PTRWM_EXP_NotFSM6CLA1A_23,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
|
||||||
289,PTRWM_HIGH_NotFSM6CLA1A_23,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
|
||||||
290,READPROG_23,Reading progress measure - two years prior
|
|
||||||
291,READPROG_LOWER_23,Reading progress measure - lower confidence limit - two years prior
|
|
||||||
292,READPROG_UPPER_23,Reading progress measure - upper confidence limit - two years prior
|
|
||||||
293,WRITPROG_23,Writing progress measure - two years prior
|
|
||||||
294,WRITPROG_LOWER_23,Writing progress measure - lower confidence limit - two years prior
|
|
||||||
295,WRITPROG_UPPER_23,Writing progress measure - upper confidence limit - two years prior
|
|
||||||
296,MATPROG_23,Maths progress measure - two years prior
|
|
||||||
297,MATPROG_LOWER_23,Maths progress measure - lower confidence limit - two years prior
|
|
||||||
298,MATPROG_UPPER_23,Maths progress measure - upper confidence limit - two years prior
|
|
||||||
299,READ_AVERAGE_23,Average scaled score in reading - two years prior
|
|
||||||
300,MAT_AVERAGE_23,Average scaled score in maths - two years prior
|
|
||||||
301,TELIG_3YR,Total number of pupils at the end of Key Stage 2 over the past three years
|
|
||||||
302,PTRWM_EXP_3YR,"Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total"
|
|
||||||
303,PTRWM_HIGH_3YR,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
|
|
||||||
304,READ_AVERAGE_3YR,Average scaled score in reading - 3 year average
|
|
||||||
305,MAT_AVERAGE_3YR,Average scaled score in maths - 3 year average
|
|
||||||
306,READPROG_UNADJUSTED,Unadjusted reading progress measure [not populated in 2025]
|
|
||||||
307,WRITPROG_UNADJUSTED,Unadjusted writing progress measure [not populated in 2025]
|
|
||||||
308,MATPROG_UNADJUSTED,Unadjusted maths progress measure [not populated in 2025]
|
|
||||||
309,READPROG_DESCR,Reading progress measure 'description' [not populated in 2025]
|
|
||||||
310,WRITPROG_DESCR,Writing progress measure 'description' [not populated in 2025]
|
|
||||||
311,MATPROG_DESCR,Maths progress measure 'description' [not populated in 2025]
|
|
||||||
|
@@ -1,154 +0,0 @@
|
|||||||
LEA,LA Name,REGION,REGION NAME
|
|
||||||
841,Darlington,1,North East A
|
|
||||||
840,County Durham,1,North East A
|
|
||||||
805,Hartlepool,1,North East A
|
|
||||||
806,Middlesbrough,1,North East A
|
|
||||||
807,Redcar and Cleveland,1,North East A
|
|
||||||
808,Stockton-on-Tees,1,North East A
|
|
||||||
390,Gateshead,3,North East B
|
|
||||||
391,Newcastle upon Tyne,3,North East B
|
|
||||||
392,North Tyneside,3,North East B
|
|
||||||
929,Northumberland,3,North East B
|
|
||||||
393,South Tyneside,3,North East B
|
|
||||||
394,Sunderland,3,North East B
|
|
||||||
889,Blackburn with Darwen,6,North West A
|
|
||||||
890,Blackpool,6,North West A
|
|
||||||
942,Cumberland,6,North West A
|
|
||||||
943,Westmorland and Furness ,6,North West A
|
|
||||||
888,Lancashire,6,North West A
|
|
||||||
350,Bolton,7,North West B
|
|
||||||
351,Bury,7,North West B
|
|
||||||
352,Manchester,7,North West B
|
|
||||||
353,Oldham,7,North West B
|
|
||||||
354,Rochdale,7,North West B
|
|
||||||
355,Salford,7,North West B
|
|
||||||
356,Stockport,7,North West B
|
|
||||||
357,Tameside,7,North West B
|
|
||||||
358,Trafford,7,North West B
|
|
||||||
359,Wigan,7,North West B
|
|
||||||
895,Cheshire East,9,North West C
|
|
||||||
896,Cheshire West and Chester,9,North West C
|
|
||||||
876,Halton,9,North West C
|
|
||||||
340,Knowsley,9,North West C
|
|
||||||
341,Liverpool,9,North West C
|
|
||||||
343,Sefton,9,North West C
|
|
||||||
342,St. Helens,9,North West C
|
|
||||||
877,Warrington,9,North West C
|
|
||||||
344,Wirral,9,North West C
|
|
||||||
811,East Riding of Yorkshire,10,North Yorkshire and The Humber
|
|
||||||
810,"Kingston Upon Hull, City of",10,North Yorkshire and The Humber
|
|
||||||
812,North East Lincolnshire,10,North Yorkshire and The Humber
|
|
||||||
813,North Lincolnshire,10,North Yorkshire and The Humber
|
|
||||||
815,North Yorkshire,10,North Yorkshire and The Humber
|
|
||||||
816,York,10,North Yorkshire and The Humber
|
|
||||||
370,Barnsley,12,South and West Yorkshire
|
|
||||||
380,Bradford,12,South and West Yorkshire
|
|
||||||
381,Calderdale,12,South and West Yorkshire
|
|
||||||
371,Doncaster,12,South and West Yorkshire
|
|
||||||
382,Kirklees,12,South and West Yorkshire
|
|
||||||
383,Leeds,12,South and West Yorkshire
|
|
||||||
372,Rotherham,12,South and West Yorkshire
|
|
||||||
373,Sheffield,12,South and West Yorkshire
|
|
||||||
384,Wakefield,12,South and West Yorkshire
|
|
||||||
831,Derby,14,East Midlands A
|
|
||||||
830,Derbyshire,14,East Midlands A
|
|
||||||
892,Nottingham,14,East Midlands A
|
|
||||||
891,Nottinghamshire,14,East Midlands A
|
|
||||||
856,Leicester,16,East Midlands B
|
|
||||||
855,Leicestershire,16,East Midlands B
|
|
||||||
925,Lincolnshire,16,East Midlands B
|
|
||||||
940,North Northamptonshire,16,East Midlands B
|
|
||||||
941,West Northamptonshire,16,East Midlands B
|
|
||||||
857,Rutland,16,East Midlands B
|
|
||||||
893,Shropshire,20,West Midlands A
|
|
||||||
860,Staffordshire,20,West Midlands A
|
|
||||||
861,Stoke-on-Trent,20,West Midlands A
|
|
||||||
894,Telford and Wrekin,20,West Midlands A
|
|
||||||
884,"Herefordshire, County of",22,West Midlands B
|
|
||||||
885,Worcestershire,22,West Midlands B
|
|
||||||
330,Birmingham,24,West Midlands C
|
|
||||||
331,Coventry,24,West Midlands C
|
|
||||||
332,Dudley,24,West Midlands C
|
|
||||||
333,Sandwell,24,West Midlands C
|
|
||||||
334,Solihull,24,West Midlands C
|
|
||||||
335,Walsall,24,West Midlands C
|
|
||||||
937,Warwickshire,24,West Midlands C
|
|
||||||
336,Wolverhampton,24,West Midlands C
|
|
||||||
822,Bedford,25,East of England A
|
|
||||||
873,Cambridgeshire,25,East of England A
|
|
||||||
823,Central Bedfordshire,25,East of England A
|
|
||||||
919,Hertfordshire,25,East of England A
|
|
||||||
821,Luton,25,East of England A
|
|
||||||
874,Peterborough,25,East of England A
|
|
||||||
881,Essex,27,East of England B
|
|
||||||
926,Norfolk,27,East of England B
|
|
||||||
882,Southend-on-Sea,27,East of England B
|
|
||||||
935,Suffolk,27,East of England B
|
|
||||||
883,Thurrock,27,East of England B
|
|
||||||
202,Camden,31,London Central
|
|
||||||
206,Islington,31,London Central
|
|
||||||
207,Kensington and Chelsea,31,London Central
|
|
||||||
208,Lambeth,31,London Central
|
|
||||||
210,Southwark,31,London Central
|
|
||||||
212,Wandsworth,31,London Central
|
|
||||||
213,Westminster,31,London Central
|
|
||||||
301,Barking and Dagenham,32,London East
|
|
||||||
303,Bexley,32,London East
|
|
||||||
201,City of London,32,London East
|
|
||||||
203,Greenwich,32,London East
|
|
||||||
204,Hackney,32,London East
|
|
||||||
311,Havering,32,London East
|
|
||||||
209,Lewisham,32,London East
|
|
||||||
316,Newham,32,London East
|
|
||||||
317,Redbridge,32,London East
|
|
||||||
211,Tower Hamlets,32,London East
|
|
||||||
302,Barnet,33,London North
|
|
||||||
308,Enfield,33,London North
|
|
||||||
309,Haringey,33,London North
|
|
||||||
320,Waltham Forest,33,London North
|
|
||||||
305,Bromley,34,London South
|
|
||||||
306,Croydon,34,London South
|
|
||||||
314,Kingston upon Thames,34,London South
|
|
||||||
315,Merton,34,London South
|
|
||||||
318,Richmond upon Thames,34,London South
|
|
||||||
319,Sutton,34,London South
|
|
||||||
304,Brent,35,London West
|
|
||||||
307,Ealing,35,London West
|
|
||||||
205,Hammersmith and Fulham,35,London West
|
|
||||||
310,Harrow,35,London West
|
|
||||||
312,Hillingdon,35,London West
|
|
||||||
313,Hounslow,35,London West
|
|
||||||
867,Bracknell Forest,36,South East A
|
|
||||||
825,Buckinghamshire,36,South East A
|
|
||||||
826,Milton Keynes,36,South East A
|
|
||||||
931,Oxfordshire,36,South East A
|
|
||||||
870,Reading,36,South East A
|
|
||||||
871,Slough,36,South East A
|
|
||||||
869,West Berkshire,36,South East A
|
|
||||||
868,Windsor and Maidenhead,36,South East A
|
|
||||||
872,Wokingham,36,South East A
|
|
||||||
850,Hampshire,37,South East B
|
|
||||||
921,Isle of Wight,37,South East B
|
|
||||||
851,Portsmouth,37,South East B
|
|
||||||
852,Southampton,37,South East B
|
|
||||||
936,Surrey,38,South East C
|
|
||||||
938,West Sussex,38,South East C
|
|
||||||
846,Brighton and Hove,39,South East D
|
|
||||||
845,East Sussex,39,South East D
|
|
||||||
886,Kent,39,South East D
|
|
||||||
887,Medway,39,South East D
|
|
||||||
839,"Bournemouth, Christchurch and Poole",43,South West A
|
|
||||||
908,Cornwall,43,South West A
|
|
||||||
878,Devon,43,South West A
|
|
||||||
838,Dorset,43,South West A
|
|
||||||
420,Isles of Scilly,43,South West A
|
|
||||||
879,Plymouth,43,South West A
|
|
||||||
933,Somerset,43,South West A
|
|
||||||
880,Torbay,43,South West A
|
|
||||||
800,Bath and North East Somerset,45,South West B
|
|
||||||
801,"Bristol, City of",45,South West B
|
|
||||||
916,Gloucestershire,45,South West B
|
|
||||||
802,North Somerset,45,South West B
|
|
||||||
803,South Gloucestershire,45,South West B
|
|
||||||
866,Swindon,45,South West B
|
|
||||||
865,Wiltshire,45,South West B
|
|
||||||
|
@@ -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:
|
||||||
@@ -7,9 +7,7 @@
|
|||||||
# ADMIN_API_KEY — Backend admin API key
|
# ADMIN_API_KEY — Backend admin API key
|
||||||
# TYPESENSE_API_KEY — Typesense admin API key
|
# TYPESENSE_API_KEY — Typesense admin API key
|
||||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||||
# KESTRA_USER — Kestra UI username (optional)
|
|
||||||
# KESTRA_PASSWORD — Kestra UI password (optional)
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ services:
|
|||||||
|
|
||||||
# ── FastAPI Backend ───────────────────────────────────────────────────
|
# ── FastAPI Backend ───────────────────────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
image: privaterepo.sitaru.org/tudor/school_compare-backend:latest
|
image: privaterepo.sitaru.org/tudor/school_compare-backend:prod
|
||||||
container_name: schoolcompare_backend
|
container_name: schoolcompare_backend
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||||
@@ -61,7 +59,7 @@ services:
|
|||||||
|
|
||||||
# ── Next.js Frontend ──────────────────────────────────────────────────
|
# ── Next.js Frontend ──────────────────────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
image: privaterepo.sitaru.org/tudor/school_compare-frontend:latest
|
image: privaterepo.sitaru.org/tudor/school_compare-frontend:prod
|
||||||
container_name: schoolcompare_nextjs
|
container_name: schoolcompare_nextjs
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
@@ -103,90 +101,9 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# ── Kestra — workflow orchestrator (legacy, kept during migration) ────
|
|
||||||
kestra:
|
|
||||||
image: kestra/kestra:latest
|
|
||||||
container_name: schoolcompare_kestra
|
|
||||||
command: server standalone
|
|
||||||
ports:
|
|
||||||
- "8090:8080"
|
|
||||||
volumes:
|
|
||||||
- kestra_storage:/app/storage
|
|
||||||
environment:
|
|
||||||
KESTRA_CONFIGURATION: |
|
|
||||||
datasources:
|
|
||||||
postgres:
|
|
||||||
url: jdbc:postgresql://sc_database:5432/kestra
|
|
||||||
driverClassName: org.postgresql.Driver
|
|
||||||
username: ${DB_USERNAME}
|
|
||||||
password: ${DB_PASSWORD}
|
|
||||||
kestra:
|
|
||||||
repository:
|
|
||||||
type: postgres
|
|
||||||
queue:
|
|
||||||
type: postgres
|
|
||||||
storage:
|
|
||||||
type: local
|
|
||||||
local:
|
|
||||||
base-path: /app/storage
|
|
||||||
depends_on:
|
|
||||||
sc_database:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/health | grep -q '\"status\":\"UP\"'"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 60s
|
|
||||||
|
|
||||||
# ── Kestra init (legacy, kept during migration) ──────────────────────
|
|
||||||
kestra-init:
|
|
||||||
image: privaterepo.sitaru.org/tudor/school_compare-kestra-init:latest
|
|
||||||
container_name: schoolcompare_kestra_init
|
|
||||||
environment:
|
|
||||||
KESTRA_URL: http://kestra:8080
|
|
||||||
KESTRA_USER: ${KESTRA_USER:-}
|
|
||||||
KESTRA_PASSWORD: ${KESTRA_PASSWORD:-}
|
|
||||||
depends_on:
|
|
||||||
kestra:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
# ── Data integrator (legacy, kept during migration) ──────────────────
|
|
||||||
integrator:
|
|
||||||
image: privaterepo.sitaru.org/tudor/school_compare-integrator:latest
|
|
||||||
container_name: schoolcompare_integrator
|
|
||||||
ports:
|
|
||||||
- "8001:8001"
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
|
||||||
DATA_DIR: /data
|
|
||||||
BACKEND_URL: http://backend:80
|
|
||||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
|
||||||
PYTHONUNBUFFERED: 1
|
|
||||||
volumes:
|
|
||||||
- supplementary_data:/data
|
|
||||||
depends_on:
|
|
||||||
sc_database:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
# ── Airflow API Server + UI ───────────────────────────────────────────
|
# ── Airflow API Server + UI ───────────────────────────────────────────
|
||||||
airflow-api-server:
|
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
|
container_name: schoolcompare_airflow_api
|
||||||
command: airflow api-server --port 8080
|
command: airflow api-server --port 8080
|
||||||
ports:
|
ports:
|
||||||
@@ -225,7 +142,7 @@ services:
|
|||||||
|
|
||||||
# ── Airflow Scheduler ──────────────────────────────────────────────
|
# ── Airflow Scheduler ──────────────────────────────────────────────
|
||||||
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
|
container_name: schoolcompare_airflow_scheduler
|
||||||
command: airflow scheduler
|
command: airflow scheduler
|
||||||
environment:
|
environment:
|
||||||
@@ -255,7 +172,7 @@ services:
|
|||||||
|
|
||||||
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
|
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
|
||||||
airflow-init:
|
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
|
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"
|
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:
|
environment:
|
||||||
@@ -282,7 +199,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
kestra_storage:
|
|
||||||
supplementary_data:
|
|
||||||
typesense_data:
|
typesense_data:
|
||||||
airflow_logs:
|
airflow_logs:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
POSTGRES_USER: schoolcompare
|
POSTGRES_USER: schoolcompare
|
||||||
POSTGRES_PASSWORD: schoolcompare
|
POSTGRES_PASSWORD: schoolcompare
|
||||||
POSTGRES_DB: schoolcompare
|
POSTGRES_DB: schoolcompare
|
||||||
|
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
@@ -119,6 +120,8 @@ services:
|
|||||||
PG_DATABASE: schoolcompare
|
PG_DATABASE: schoolcompare
|
||||||
TYPESENSE_URL: http://typesense:8108
|
TYPESENSE_URL: http://typesense:8108
|
||||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
BACKEND_URL: http://backend:80
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||||
volumes:
|
volumes:
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+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,381 @@
|
|||||||
|
# SchoolCompare UX/UI Audit Execution Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Execute the journey-led UX/UI audit of live schoolcompare.co.uk defined in `docs/superpowers/specs/2026-07-02-ux-audit-design.md`, producing a prioritized (P0–P3) audit report at `docs/superpowers/specs/2026-07-02-ux-audit-report.md`.
|
||||||
|
|
||||||
|
**Architecture:** Five journey walk-throughs (traffic-ordered) at two viewports using Playwright browser tools against the live site, each producing a committed notes file with friction points, "works well" observations, and axe-core scan results. A cross-cutting cohesion pass compares components across pages. A final synthesis task converts notes into the prioritized report.
|
||||||
|
|
||||||
|
**Tech Stack:** Playwright MCP browser tools (`mcp__plugin_playwright_playwright__*`), axe-core 4.x injected from CDN, markdown notes committed to git.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Audit the **live site** `https://schoolcompare.co.uk` — do not start any local server (CLAUDE.md).
|
||||||
|
- Viewports: **mobile 390×844 (primary)** and **desktop 1440×900**. Mobile findings weigh more (56% of traffic).
|
||||||
|
- A finding is valid only if it cites: a Nielsen/NN-g heuristic violation, a WCAG 2.2 AA failure, a mobile-usability standard, or an observed task-flow obstruction. No taste-only findings.
|
||||||
|
- Every finding: **evidence → argument (why it hurts parents) → recommendation → uplift indication** (which analytics number moves, direction, small/moderate/large band with reasoning — never invented percentages).
|
||||||
|
- Read-only with respect to the site: browse and inspect only; never submit forms that create/modify data (search and filter interactions are fine).
|
||||||
|
- Notes live in `docs/superpowers/specs/2026-07-02-ux-audit-notes/`; screenshots go to the session scratchpad (referenced by filename in notes, not committed).
|
||||||
|
- Analytics baseline for weighting (30 days): entries `/` 63%, `/compare` 20%, `/rankings` 6%; exits `/` 46%, `/compare` 32%, `/rankings` 13%; views `/` 52%, `/compare` 27%, `/rankings` 12%, `/admissions` 5%; school pages ~1% each (SEO long tail); 56% mobile.
|
||||||
|
|
||||||
|
**Note on tool schemas:** Playwright tools are deferred. Before first use in any task, load them:
|
||||||
|
`ToolSearch` with query `select:mcp__plugin_playwright_playwright__browser_navigate,mcp__plugin_playwright_playwright__browser_resize,mcp__plugin_playwright_playwright__browser_snapshot,mcp__plugin_playwright_playwright__browser_take_screenshot,mcp__plugin_playwright_playwright__browser_evaluate,mcp__plugin_playwright_playwright__browser_click,mcp__plugin_playwright_playwright__browser_type,mcp__plugin_playwright_playwright__browser_press_key,mcp__plugin_playwright_playwright__browser_console_messages`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Audit scaffolding + axe-core harness verified on the homepage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/axe-snippet.js`
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/TEMPLATE.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `axe-snippet.js` — a self-contained async JS function body for `browser_evaluate` that loads axe-core from CDN (idempotent) and returns `{violationCount, violations: [{id, impact, description, nodes: count, sampleTargets}]}` filtered to WCAG 2.2 A/AA rules. All journey tasks run this verbatim on each page state.
|
||||||
|
- Produces: `TEMPLATE.md` — the notes-file structure every journey task copies.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the axe harness snippet**
|
||||||
|
|
||||||
|
Create `docs/superpowers/specs/2026-07-02-ux-audit-notes/axe-snippet.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Body for playwright browser_evaluate: () => { ...this content... }
|
||||||
|
// Loads axe-core 4.x from CDN (skips if already present), runs WCAG A/AA scan.
|
||||||
|
return (async () => {
|
||||||
|
if (!window.axe) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('axe failed to load'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const results = await window.axe.run(document, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
url: location.pathname,
|
||||||
|
violationCount: results.violations.length,
|
||||||
|
violations: results.violations.map(v => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
description: v.help,
|
||||||
|
nodes: v.nodes.length,
|
||||||
|
sampleTargets: v.nodes.slice(0, 3).map(n => n.target.join(' '))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the notes template**
|
||||||
|
|
||||||
|
Create `docs/superpowers/specs/2026-07-02-ux-audit-notes/TEMPLATE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Journey N: <name> — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** <paths>
|
||||||
|
**Viewports:** 390×844, 1440×900
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
<What was attempted, step by step, and where time/attention went. Note seconds-to-goal where measurable.>
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
For each:
|
||||||
|
- **F<N>. <short title>**
|
||||||
|
- Evidence: <screenshot filename(s), observed behaviour, axe rule id if applicable>
|
||||||
|
- Criterion violated: <heuristic / WCAG SC / mobile standard / task obstruction>
|
||||||
|
- Argument: <why this hurts a parent completing the task>
|
||||||
|
- Severity guess: <P0/P1/P2/P3 — provisional, finalized in synthesis>
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- <observation, with why it works>
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
- <path> @ <viewport>: <violationCount> violations — <ids with impact>
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
- Touch targets ≥24px on interactive elements: <pass/fail + examples>
|
||||||
|
- Keyboard: tab order, focus visibility (desktop only): <pass/fail + examples>
|
||||||
|
- Zoom 200% text reflow (desktop only): <pass/fail>
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
- <filename>: <what it shows>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the harness against the live homepage**
|
||||||
|
|
||||||
|
Load Playwright tool schemas (see Global Constraints note). Then:
|
||||||
|
1. `browser_resize` to 390×844.
|
||||||
|
2. `browser_navigate` to `https://schoolcompare.co.uk/`.
|
||||||
|
3. `browser_evaluate` with the contents of `axe-snippet.js` as the function body.
|
||||||
|
|
||||||
|
Expected: a JSON result with `violationCount` (any number ≥ 0) and no thrown error. If the CDN is blocked, switch the `src` to `https://unpkg.com/axe-core@4.10.2/axe.min.js` in the file and re-verify.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Take a baseline screenshot to confirm capture works**
|
||||||
|
|
||||||
|
`browser_take_screenshot` with filename `home-mobile-baseline.png`. Expected: file saved, path returned.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/
|
||||||
|
git commit -m "chore(audit): axe harness and notes template for UX audit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Journey 1 — Home → find my school (63% of entries, 46% exits)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-1-home-find-school.md` (copy structure from `TEMPLATE.md`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `axe-snippet.js` via `browser_evaluate`; `TEMPLATE.md` structure.
|
||||||
|
- Produces: `journey-1-home-find-school.md` — notes consumed by Task 8 synthesis.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mobile walk-through (390×844)**
|
||||||
|
|
||||||
|
1. `browser_resize` 390×844, `browser_navigate` `https://schoolcompare.co.uk/`.
|
||||||
|
2. `browser_snapshot` — record what is above the fold: is the search input visible without scrolling? What competes for attention? Screenshot `j1-home-mobile-fold.png`.
|
||||||
|
3. Scroll the full page (`browser_press_key` End or evaluate `window.scrollTo`), screenshot `j1-home-mobile-full.png`. Note content order: does featured/secondary content precede the primary task?
|
||||||
|
4. **Task attempt A (school by name):** type a real school name into search, e.g. `Welland Primary` (known from analytics). Record: keystrokes-to-results, result quality (is the right school first?), loading feedback, and taps needed to reach `/school/146678-welland-primary-school`. Screenshot the results state `j1-search-results-mobile.png`.
|
||||||
|
5. **Task attempt B (postcode):** return home, search a plausible postcode (e.g. `B91 3` area for Solihull, or any valid UK postcode like `SW1A 1AA`). Record: is postcode search discoverable/labelled? Distance shown? Sensible ordering? Screenshot `j1-postcode-results-mobile.png`.
|
||||||
|
6. Record time-to-school-page for both attempts against the spec's ~15s target.
|
||||||
|
7. Run axe snippet on: home (initial) and home (results visible). Record results.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Desktop walk-through (1440×900)**
|
||||||
|
|
||||||
|
Repeat Step 1's navigation and task attempt A at 1440×900 (screenshots `j1-home-desktop-fold.png`, `j1-search-results-desktop.png`). Additionally: tab through the page with keyboard — record focus visibility and whether search → results → school link is keyboard-operable. Test 200% zoom (`browser_evaluate` `document.body.style.zoom` is NOT valid for this — instead resize to 720×450 which approximates 200% reflow at 1440) and note any loss of content/overlap.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write notes file**
|
||||||
|
|
||||||
|
Fill `journey-1-home-find-school.md` per template. Every friction point needs evidence + criterion + argument. Explicitly answer: "why do 46% of visitors exit at home?" — list the plausible causes observed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-1-home-find-school.md
|
||||||
|
git commit -m "docs(audit): journey 1 notes — home to school search"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Journey 2 — Cold landing on a school detail page (SEO long tail)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-2-school-detail-cold.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
|
||||||
|
- Produces: `journey-2-school-detail-cold.md` for Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mobile cold landing (390×844)**
|
||||||
|
|
||||||
|
Navigate **directly** (no prior site context — this simulates a Google arrival) to `https://schoolcompare.co.uk/school/136916-the-castle-school`. Then assess in order:
|
||||||
|
1. First-screen orientation: within one screen, can a parent tell what site this is, what school this is, and what the site offers? Screenshot `j2-school-mobile-fold.png`.
|
||||||
|
2. Scroll the entire page. Screenshots at each major section (`j2-school-mobile-<section>.png`). For each data block (results, Ofsted, characteristics, admissions): is it comprehensible to a non-specialist? Is jargon (RWM, expected standard, progress scores) explained in place?
|
||||||
|
3. Next-step paths: is there an obvious "compare this school" and "schools near this one" action? How many taps to a comparison including this school? Record the exact path or its absence.
|
||||||
|
4. Repeat the cold landing for a contrasting school `https://schoolcompare.co.uk/school/146678-welland-primary-school` (different data availability) — note any layout breakage or missing-data handling. Screenshot anomalies only.
|
||||||
|
5. Run axe snippet on both school pages. Record results.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Desktop pass (1440×900)**
|
||||||
|
|
||||||
|
Reload `.../136916-the-castle-school` at desktop. Screenshot `j2-school-desktop-fold.png`. Check: hero/map rendering, chart legibility, keyboard focus through interactive elements, link affordance (do school-page links look clickable?).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write notes file**
|
||||||
|
|
||||||
|
Fill `journey-2-school-detail-cold.md`. Explicitly answer: "a parent lands here from Google — what would make them stay and use the site rather than bounce back to search results?"
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-2-school-detail-cold.md
|
||||||
|
git commit -m "docs(audit): journey 2 notes — cold landing on school detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Journey 3 — Building a comparison (27% of views, 32% exits)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-3-compare.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
|
||||||
|
- Produces: `journey-3-compare.md` for Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mobile walk-through (390×844)**
|
||||||
|
|
||||||
|
1. Navigate to `https://schoolcompare.co.uk/compare` **directly** (20% of sessions enter here). Screenshot empty state `j3-compare-empty-mobile.png`. Is the empty state instructive — does it tell a parent what to do first?
|
||||||
|
2. Add two schools via whatever mechanism the page offers (search within compare, or navigate to school pages and use their compare action — record which paths exist). Count taps from empty state to a two-school comparison. Screenshot `j3-compare-two-schools-mobile.png`.
|
||||||
|
3. Assess the comparison output on mobile: are charts/tables legible at 390px? Horizontal scrolling? Can you tell which school is which (colour + label, not colour alone — WCAG 1.4.1)? Are metric names explained?
|
||||||
|
4. Remove a school; add a third. Any state loss, confusing controls, or dead ends? Does the selection persist if you navigate away and back?
|
||||||
|
5. Run axe snippet on empty state and populated state. Record results.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Desktop pass (1440×900)**
|
||||||
|
|
||||||
|
Repeat comparison-building at desktop. Screenshot `j3-compare-two-schools-desktop.png`. Keyboard-operate the add/remove flow; record focus behaviour. Check chart tooltips/legends for mouse-only interactions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write notes file**
|
||||||
|
|
||||||
|
Fill `journey-3-compare.md`. Explicitly answer: "compare is 32% of exits — is that task-complete satisfaction (fine) or abandonment (problem)? What observed evidence points either way?"
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-3-compare.md
|
||||||
|
git commit -m "docs(audit): journey 3 notes — building a comparison"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Journey 4 — Rankings → shortlist (12% of views)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-4-rankings.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
|
||||||
|
- Produces: `journey-4-rankings.md` for Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mobile walk-through (390×844)**
|
||||||
|
|
||||||
|
1. Navigate to `https://schoolcompare.co.uk/rankings`. Screenshot default state `j4-rankings-mobile.png`.
|
||||||
|
2. Is the default ranking explained (which metric, which year, what the numbers mean)? Would a parent understand what "top" means here?
|
||||||
|
3. Filter to a local authority (e.g. Solihull). Count taps; is the filter discoverable on mobile? Screenshot filtered state `j4-rankings-filtered-mobile.png`.
|
||||||
|
4. Change the ranking metric. Is the metric picker comprehensible (plain-language labels vs. jargon)?
|
||||||
|
5. Tap through to a school from the list; navigate back — is filter state preserved? (Back-navigation state loss is a classic mobile task-killer.)
|
||||||
|
6. Run axe snippet on default and filtered states. Record results.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Desktop pass (1440×900)**
|
||||||
|
|
||||||
|
Repeat at desktop, screenshot `j4-rankings-desktop.png`. Check table semantics (real `<table>` with headers vs. divs — screen-reader implications), sortability affordances, keyboard operation of filters.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write notes file + commit**
|
||||||
|
|
||||||
|
Fill `journey-4-rankings.md`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-4-rankings.md
|
||||||
|
git commit -m "docs(audit): journey 4 notes — rankings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Journey 5 — Admissions content (5% of views, light pass)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-5-admissions.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
|
||||||
|
- Produces: `journey-5-admissions.md` for Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Single mobile pass (390×844)**
|
||||||
|
|
||||||
|
1. Navigate to `https://schoolcompare.co.uk/admissions`. Screenshot `j5-admissions-mobile.png`.
|
||||||
|
2. Light checks only: readability (line length, heading hierarchy), whether the recently added SchoolCompare tool cross-links are present and useful, whether the page's look matches the rest of the site (this feeds the cohesion pass), and one axe scan.
|
||||||
|
3. Check discoverability in reverse: from the home page, how does a parent find this content at all? (5% views may be a discoverability problem rather than a demand problem — note evidence either way.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write notes file + commit**
|
||||||
|
|
||||||
|
Fill `journey-5-admissions.md` (shorter than the others is expected).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-5-admissions.md
|
||||||
|
git commit -m "docs(audit): journey 5 notes — admissions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Cross-cutting cohesion pass
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/cohesion-pass.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all journey screenshots (scratchpad) and notes files; live site; optionally `nextjs-app` source for token verification.
|
||||||
|
- Produces: `cohesion-pass.md` for Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Component comparison across pages**
|
||||||
|
|
||||||
|
Using the screenshots already captured plus targeted re-visits, compare across `/`, `/compare`, `/rankings`, `/admissions`, and a school page:
|
||||||
|
1. **Typography:** collect computed styles via `browser_evaluate` on each page — e.g. `[...document.querySelectorAll('h1,h2,h3,body p, button, a')].slice(0,40).map(e => ({tag: e.tagName, size: getComputedStyle(e).fontSize, weight: getComputedStyle(e).fontWeight, family: getComputedStyle(e).fontFamily.split(',')[0]}))` — and diff the scales page-to-page. Record any page using off-scale sizes.
|
||||||
|
2. **Colour:** same technique for `color`, `backgroundColor` on buttons/links/chips; flag near-duplicate colours (e.g. two blues doing the same job) and any accent colour used inconsistently.
|
||||||
|
3. **Components:** buttons, chips, cards, empty states, loading states — screenshot side-by-side candidates and note variant drift (different radii, padding, casing, icon usage for the same semantic role).
|
||||||
|
4. **Navigation & page furniture:** header/footer consistency, page-title patterns, back-link behaviour, breadcrumbs presence/absence across page types.
|
||||||
|
5. **Recent additions check (from spec):** map-blended hero, characteristic chips, admissions cross-links — do they feel native to the rest of the site?
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify against source where ambiguous**
|
||||||
|
|
||||||
|
Where a visual inconsistency could be intentional, check `nextjs-app` styles (grep for the relevant component/tokens) to determine whether a design token exists and is being bypassed, or no token exists. Record which — it changes the recommendation (enforce token vs. create token).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write notes file + commit**
|
||||||
|
|
||||||
|
Fill `cohesion-pass.md` with the same evidence → criterion → argument structure (criterion here is typically "consistency and standards" heuristic).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/cohesion-pass.md
|
||||||
|
git commit -m "docs(audit): cross-cutting cohesion pass notes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Synthesis — prioritized audit report
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-report.md`
|
||||||
|
- Read: all files in `docs/superpowers/specs/2026-07-02-ux-audit-notes/`, spec `docs/superpowers/specs/2026-07-02-ux-audit-design.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: journey notes 1–5, cohesion notes.
|
||||||
|
- Produces: the final deliverable report.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Consolidate and deduplicate findings**
|
||||||
|
|
||||||
|
Read all six notes files. Merge duplicate findings (same root cause observed in several journeys becomes one finding listing all occurrences). Discard any finding lacking evidence or a cited criterion — the spec forbids taste-only findings.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Assign final priorities and uplift indications**
|
||||||
|
|
||||||
|
For each finding assign P0–P3 per the spec's definitions (traffic-weighted impact × severity), and an uplift line: **metric** (one of: home 46% exit rate; share of sessions reaching a school page; compare 32% exit rate; rankings→school click-through; school-page bounce-back-to-Google; accessibility compliance), **direction**, **band** (small/moderate/large) **with one-sentence reasoning**. Sanity rules: a P0/P1 must sit on `/`, `/compare`, `/rankings`, or the school-page template; admissions-only findings cap at P2 unless a WCAG failure.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the report**
|
||||||
|
|
||||||
|
Structure (from spec):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# SchoolCompare UX/UI Audit — 2026-07-02
|
||||||
|
|
||||||
|
## Method summary
|
||||||
|
<viewports, journeys, tools, analytics baseline — half a page>
|
||||||
|
|
||||||
|
## What works today — keep
|
||||||
|
<explicit list with why; protects against change-for-change's-sake>
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P0 — Urgent
|
||||||
|
<each: title, evidence (screenshots/axe ids), criterion, argument, recommendation, uplift>
|
||||||
|
|
||||||
|
### P1 — High
|
||||||
|
### P2 — Medium
|
||||||
|
### P3 — Nice-to-have
|
||||||
|
|
||||||
|
## Accessibility summary
|
||||||
|
<axe violation table by page/viewport + manual check results; overall WCAG 2.2 AA posture>
|
||||||
|
|
||||||
|
## Suggested implementation sequence
|
||||||
|
<grouped batches of related fixes, ordered by uplift-per-effort; each batch sized as a plausible follow-up project>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Self-check the report against the spec**
|
||||||
|
|
||||||
|
Verify: every finding has all four elements (evidence/argument/recommendation/uplift); "works well" section is non-empty; uplift bands never state invented percentages; P0/P1 findings all sit on high-traffic paths; report answers the spec's three goals (a) speed-to-information, (b) end-to-end cohesion, (c) standards compliance. Fix inline.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-02-ux-audit-report.md
|
||||||
|
git commit -m "docs(audit): prioritized UX/UI audit report"
|
||||||
|
```
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# UX/UI Audit of SchoolCompare.co.uk — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-02
|
||||||
|
**Status:** Approved design, awaiting execution
|
||||||
|
**Output of execution:** a prioritized audit report (this spec defines how that report is produced)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A comprehensive UX and design audit of the live site (schoolcompare.co.uk) that answers:
|
||||||
|
|
||||||
|
- **(a)** Can parents reach the relevant information quickly and intuitively?
|
||||||
|
- **(b)** Is the site visually and experientially cohesive end-to-end, not just at component level?
|
||||||
|
- **(c)** Does it comply with current design standards and best practices (incl. WCAG 2.2 AA)?
|
||||||
|
|
||||||
|
Every recommendation must be argued from evidence — no change for change's sake — and carry an indication of potential uplift. Primary success lens: **engagement and task completion** (parents who land actually reach a school page or comparison); SEO benefits noted secondarily.
|
||||||
|
|
||||||
|
## Analytics baseline (30 days, informing all weighting)
|
||||||
|
|
||||||
|
- ~2.3k visitors; 87% UK; 83% Google organic.
|
||||||
|
- Devices: **56% mobile**, 41% laptop, 2% desktop → mobile is the primary viewport.
|
||||||
|
- Page views: `/` 52%, `/compare` 27%, `/rankings` 12%, `/admissions` 5%, school pages a long tail (~1% each).
|
||||||
|
- Entries: `/` 63%, `/compare` 20%, `/rankings` 6%, individual school pages a small but real SEO tail.
|
||||||
|
- Exits: `/` 46% (biggest leak), `/compare` 32%, `/rankings` 13%.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
**Environment:** live production site via Playwright browser tools. Two viewports: **390×844 (mobile, weighted primary)** and **1440×900 (desktop)**. Chrome engine (matches 41% Chrome + 11% Edge majority); iOS/WebKit-specific rendering is out of scope (cannot be emulated faithfully).
|
||||||
|
|
||||||
|
**Journey-led walk-through, in traffic order:**
|
||||||
|
|
||||||
|
1. **Home → find my school** (63% of entries; 46% of exits). Can a parent with a school name or postcode reach the right school page in under ~15 seconds? What competes for attention?
|
||||||
|
2. **Google → school detail page → next step** (SEO long tail). Landing cold: is site identity obvious, is performance data comprehensible to a non-specialist parent, is there a clear path to "compare with nearby schools"?
|
||||||
|
3. **Building a comparison** (27% of views, 32% of exits). Adding/removing schools, chart legibility on mobile, metric comprehension.
|
||||||
|
4. **Rankings → shortlist** (12% of views). Filtering by local authority, scanning, jumping to school pages.
|
||||||
|
5. **Admissions content** (5% — light pass, mainly cohesion with the rest of the site).
|
||||||
|
|
||||||
|
**Per journey, record:** friction points with screenshot evidence; what works well (explicitly kept); axe-core accessibility scan of each page state visited.
|
||||||
|
|
||||||
|
**Cross-cutting cohesion pass** (after journeys): typography scale, spacing rhythm, colour usage, component variants (buttons, chips, cards, nav, empty/loading states) compared **across** pages; consistency check of recent additions (map-blended hero, characteristic chips, admissions cross-links).
|
||||||
|
|
||||||
|
## Evaluation criteria
|
||||||
|
|
||||||
|
A finding is valid only if it cites at least one of:
|
||||||
|
|
||||||
|
- a violated usability heuristic (Nielsen/NN-g),
|
||||||
|
- a WCAG 2.2 AA failure (axe result or manual check: contrast, touch targets ≥24px, keyboard/focus, labels),
|
||||||
|
- a mobile-usability standard,
|
||||||
|
- a concrete task-flow obstruction observed in the walk-through.
|
||||||
|
|
||||||
|
No "I'd prefer it differently" findings. Each finding: **evidence → argument (why it hurts parents) → recommendation**.
|
||||||
|
|
||||||
|
## Prioritization
|
||||||
|
|
||||||
|
Scored on traffic-weighted impact × severity:
|
||||||
|
|
||||||
|
- **P0 Urgent** — blocks or badly degrades a core task on a high-traffic path, or a clear WCAG failure on a main page.
|
||||||
|
- **P1 High** — meaningful friction on a main journey; strong expected funnel uplift.
|
||||||
|
- **P2 Medium** — cohesion/polish issues that erode trust but don't block tasks.
|
||||||
|
- **P3 Nice-to-have** — low-traffic pages or marginal refinements.
|
||||||
|
|
||||||
|
**Uplift indication per finding:** which analytics number it should move (e.g. "home 46% exit rate", "share of sessions reaching a school page"), direction, and a magnitude band (small/moderate/large) with reasoning. Honest bands, not invented percentages — there is no baseline funnel instrumentation.
|
||||||
|
|
||||||
|
## Deliverable
|
||||||
|
|
||||||
|
One audit report at `docs/superpowers/specs/2026-07-02-ux-audit-report.md` containing:
|
||||||
|
|
||||||
|
1. Method summary.
|
||||||
|
2. **"What works today — keep"** list.
|
||||||
|
3. Findings grouped by tier (P0–P3), each with evidence, argument, recommendation, uplift indication.
|
||||||
|
4. Suggested implementation sequence grouping related fixes.
|
||||||
|
|
||||||
|
The report is the plan requested. Implementation of any fixes is a separate follow-up with its own plan.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Performance/Core Web Vitals auditing (explicitly excluded by choice of scope).
|
||||||
|
- iOS/Safari-specific rendering verification.
|
||||||
|
- Changes to data content or backend behaviour.
|
||||||
|
- Actually implementing fixes.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Journey N: <name> — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** <paths>
|
||||||
|
**Viewports:** 390×844, 1440×900
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
<What was attempted, step by step, and where time/attention went. Note seconds-to-goal where measurable.>
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
For each:
|
||||||
|
- **F<N>. <short title>**
|
||||||
|
- Evidence: <screenshot filename(s), observed behaviour, axe rule id if applicable>
|
||||||
|
- Criterion violated: <heuristic / WCAG SC / mobile standard / task obstruction>
|
||||||
|
- Argument: <why this hurts a parent completing the task>
|
||||||
|
- Severity guess: <P0/P1/P2/P3 — provisional, finalized in synthesis>
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- <observation, with why it works>
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
- <path> @ <viewport>: <violationCount> violations — <ids with impact>
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
- Touch targets ≥24px on interactive elements: <pass/fail + examples>
|
||||||
|
- Keyboard: tab order, focus visibility (desktop only): <pass/fail + examples>
|
||||||
|
- Zoom 200% text reflow (desktop only): <pass/fail>
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
- <filename>: <what it shows>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Body for playwright browser_evaluate: () => { ...this content... }
|
||||||
|
// Loads axe-core 4.x from CDN (skips if already present), runs WCAG A/AA scan.
|
||||||
|
return (async () => {
|
||||||
|
if (!window.axe) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('axe failed to load'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const results = await window.axe.run(document, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
url: location.pathname,
|
||||||
|
violationCount: results.violations.length,
|
||||||
|
violations: results.violations.map(v => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
description: v.help,
|
||||||
|
nodes: v.nodes.length,
|
||||||
|
sampleTargets: v.nodes.slice(0, 3).map(n => n.target.join(' '))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Cross-cutting cohesion pass — audit notes (Task 7)
|
||||||
|
|
||||||
|
**Pages compared:** `/`, `/compare`, `/rankings`, `/admissions`, `/school/136916-the-castle-school` (secondary), `/school/146678-welland-primary-school` (primary)
|
||||||
|
**Viewport:** 1440×900 default; mobile 390×844 for the /compare 3-school check.
|
||||||
|
**Method:** Ran a `browser_evaluate` typography+colour collector on each of the six live pages (headings, body, buttons, links, chips — computed `fontSize/fontWeight/fontFamily`, `color/backgroundColor/borderRadius/padding`). Diffed the results into the tables below. For every ambiguous drift, grepped `nextjs-app/` (`app/globals.css` + `components/*.module.css`) to decide **token exists & bypassed (→ enforce)** vs **no token (→ create)**.
|
||||||
|
**Criterion used throughout:** Nielsen #4 "Consistency and standards" unless a WCAG SC is named. No taste-only findings — every claim below carries computed-style and/or source evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
Collected computed styles on all six pages in sequence, then verified against source. The dominant story is **two axes of drift**: (a) the two school-detail pages are *parallel component implementations* (`SchoolDetailView` vs `SecondarySchoolDetailView`) whose tokens have diverged; (b) a rich design-token system exists in `globals.css` (`--radius-*`, `--accent-*`) but is pervasively **bypassed with hardcoded px/hex** in the module CSS.
|
||||||
|
|
||||||
|
### OPEN item — /compare with THREE same-phase schools at 390×844
|
||||||
|
Loaded `/compare?urns=142161,113105,124613&metric=rwm_expected_pct` at 390×844 — three **primary** schools (St Mary & St Thomas CofE, Ottery St Mary, Trimley St Mary), same phase, confirmed via the "Primary (3) / Secondary (0)" phase tabs. Handling is sound: the three school cards **stack vertically** (each showing name, LA, type, and the selected metric value in its series colour — 77.0% teal, 51.0% coral, 44.0% blue), so no card is squeezed. The "Performance Over Time" chart canvas renders all three series (verified: canvas has drawn content — 9,720 non-white pixel samples in a 324×300 canvas; a full-page screenshot showed it momentarily blank, which is a Chart.js/`fullPage` capture artifact, not a real defect). The "Detailed Comparison" table becomes 4 columns (metric label + 3 schools) at 869px inside a 324px container: it scrolls **horizontally within its own `overflow-x:auto` wrapper**, and the page body itself does **not** overflow (`document.scrollWidth` = 390 = `window.innerWidth`). So a third same-phase school is legible and contained — no layout break. Only nit (already a general finding, not compare-specific): at 390px only the first school's column is visible without scrolling, so a 3-way visual scan of the table requires swiping. Screenshot: `cohesion-compare-3school-mobile.jpeg`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography comparison (per page, desktop 1440)
|
||||||
|
|
||||||
|
| Page | Page-title H1 | H1 family | Section H2/H3 | Notable body sizes | Off-scale / cross-page flags |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `/` | **48px** /700 Playfair | Playfair | H2 28px & 21.6px; H3 16px & 20px Playfair | 16.8, 14.72, 14, 13.76, 13.12px | Search CTA button **20px** /600 (see F1); many near-dup body sizes |
|
||||||
|
| `/rankings` | **36px** /700 Playfair | Playfair | — | table TH 12px, TD 15px/16px | H1 smaller than home; table type 12/15 |
|
||||||
|
| `/compare` | **36px** /700 Playfair | Playfair | H2 **18px & 24px** Playfair (mixed) | table TH 12px, TD 15px | 3× **BUTTON 20px Arial** (off-family, F7); mixed H2 |
|
||||||
|
| `/admissions` | **44px** /700 Playfair | Playfair | H2 24px & 21.6px Playfair; **H3 15.2px /700 DM Sans** | 14.08, 14.4, 14, 13.12px | H3 rendered in **body font** not Playfair (F7); 3rd distinct H1 size |
|
||||||
|
| `/school/…primary` (Welland) | **52px** /700 Playfair | Playfair | H2 18px Playfair ×5; H3 14px DM Sans | table TH **11px**, TD **13px** | largest H1; table type 11/13 (F8) |
|
||||||
|
| `/school/…secondary` (Castle) | **52px** /700 Playfair | Playfair | H2 18px Playfair ×4; H3 14px DM Sans | table TH **11px**, TD **13px** | matches Welland (good); table 11/13 (F8) |
|
||||||
|
|
||||||
|
**Diff summary:** Four distinct page-title sizes across five pages — **36 / 44 / 48 / 52px**. Tool pages (rankings, compare) agree on 36; the three "hero" pages (home 48, admissions 44, school 52) each pick a different size. Source: each is a **hardcoded `clamp()`** in its own module, not a shared token — home `.hero-title` `clamp(2rem,5vw,3.5rem)`; both school `.schoolName` `clamp(2rem,5vw,3.25rem)`; admissions title `2.75rem`. → **no page-title/hero token exists → CREATE.** Body copy shows a cloud of near-duplicate sizes (0.875/0.88/0.9/0.92rem → 14/14.08/14.4/14.72px) that no single scale explains.
|
||||||
|
|
||||||
|
Fonts are otherwise disciplined: **DM Sans** body + **Playfair Display** display everywhere. Exceptions: (a) admissions/school **H3 elements render in DM Sans** while H2 stays Playfair (F7); (b) `Arial`/`Helvetica Neue` buttons+links on chart and map pages are **third-party** (Chart.js legend, Leaflet zoom/attribution) — noted, not a first-party defect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Colour comparison (buttons / links / chips)
|
||||||
|
|
||||||
|
| Role | Colour(s) observed | Token | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Primary CTA bg | `rgb(224,114,86)` `#e07256` everywhere (search, +Add School, active phase tab, btn-primary) | `--accent-coral` | Consistent ✓ |
|
||||||
|
| Primary CTA **hover** | `#c45a3f` (`btn-primary`) **vs `#d4654a`** (`.btn-compare:hover`, globals.css:754) | `--accent-coral-dark` = #c45a3f | **Near-dup / same-role different colour (F6)** — #d4654a hardcoded, bypasses token |
|
||||||
|
| Secondary / supporting | `rgb(45,125,125)` `#2d7d7d` (teal outline btn, teal chips, "Near me", info) | `--accent-teal` | Consistent ✓ |
|
||||||
|
| Nav active | coral text on `rgba(224,114,86,0.12)` tint | `--accent-coral-bg` | Consistent ✓ |
|
||||||
|
| Gold accent (admissions cross-link badge on school page) | `rgb(184,146,14)` `#b8920e` | tokens are `--accent-gold #c9a227` / `--accent-gold-text #7a6800` | **Third gold** — matches neither token (F6 family) |
|
||||||
|
| Chip series colours (countdown / SATs) | coral `#e07256`, teal `#2d7d7d`, blue chart-5 | `--chart-*` | Consistent ✓ (data encoding) |
|
||||||
|
| Map furniture links | `rgb(0,120,168)` `#0078a8` blue | none (Leaflet/OSM) | Third-party — off-palette blue leaks into school hero (see hero verdict) |
|
||||||
|
|
||||||
|
**Cluster flags:** coral resolves to **three** values doing hover/pressed work — `#e07256`, `#c45a3f`, `#d4654a`; the middle two are the same semantic role (pressed coral) at different hex. Gold has a third off-token value `#b8920e`. Teal is clean. No different-role/same-colour collisions found (coral=primary, teal=secondary is held consistently).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component variant table
|
||||||
|
|
||||||
|
| Component / role | `/` | `/rankings` | `/compare` | `/admissions` | school (primary) | school (secondary) | Drift |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Segmented / phase switcher | search-mode toggle: **rounded** (container radius-lg, btn radius-md), coral active | phaseTab: **radius 0**, coral fill, bordered | phaseTab: **radius 0** (matches rankings ✓) | sub-nav: **radius 0**, underline, teal active | section-nav pills radius 4/999 | tab btns radius 4 | **F5** — 3+ different treatments for "switch view/phase" |
|
||||||
|
| Primary "+Compare/+Add" button | btn-primary radius 8, pad 20×40 (F1) | btn radius 8, pad 12×24 | btn radius 8, pad 12×24 | — | btnAdd **radius 8, pad 12×20** | btnAdd **radius 6, pad 8×16** | **F1 / F2** |
|
||||||
|
| Back link | — | — | — | — | topBack coral, radius 0 | topBack coral, radius 0 | consistent ✓ |
|
||||||
|
| Data table type | — | TH 12 / TD 15 | TH 12 / TD 15 | — | TH **11** / TD **13** | TH **11** / TD **13** | **F8** |
|
||||||
|
| Small badge/pill | ofsted badge radius 4 | rank badge 50% | — | deadline chip radius 12 | SATs natPill radius **4**; nav pill 999 | badge radius **3**; att8 badge radius **3** | **F4** |
|
||||||
|
| Nav header / footer | identical | identical | identical | identical | identical | identical | **consistent ✓** |
|
||||||
|
|
||||||
|
Radius scale audit across all `*.module.css`: **15 distinct raw-px radius values** in use — 4px(30×), 8px(28×), 999px(21×), 12px(19×), 3px(16×), 6px(14×), 10px(13×), 2px(8×), 16px(4×), 14px(3×), 9999px(2×), 1px(2×), 9px, 5px, 25px — against a token scale of only `--radius-sm/md/lg/xl` = 4/8/16/24. Tokens exist and are widely **bypassed**; there is **no pill token** for the 999/9999 values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. Duplicate `.btn` rule set in `globals.css` — small-button padding is dead code, sizes drift**
|
||||||
|
- Evidence: `globals.css` defines `.btn` **twice** — line 151 (radius 6px, pad `0.5rem 1rem`, font 0.875rem, 1px border) and again line 1558 (radius `--radius-md`, pad `0.75rem 1.5rem`, font 0.9rem, `border:none`). `.btn-sm` (line 218, pad `0.3rem 0.625rem`) is declared *between* them, so the later `.btn` (equal specificity, source order wins) **overrides** it. Live proof: rankings `.btn.btn-sm` computes to padding **12px 24px**, not the intended 4.8×10px. Meanwhile the home search `.btn.btn-primary` computes to pad **20px 40px** / font 20px (a FilterBar override on top).
|
||||||
|
- Criterion violated: Nielsen #4; touch-target predictability.
|
||||||
|
- Argument: "small" buttons aren't small, and the base button geometry depends on which of two conflicting blocks wins — any future button edit has a 50/50 chance of hitting the dead rule. Silent, repo-wide.
|
||||||
|
- Source verdict: **token/rule conflict → ENFORCE** (dedupe to one `.btn` definition; restore `.btn-sm`).
|
||||||
|
- Severity: **P2**.
|
||||||
|
|
||||||
|
- **F2. Two parallel school-detail components have drifted on the same controls**
|
||||||
|
- Evidence: `SchoolDetailView.module.css` vs `SecondarySchoolDetailView.module.css` implement the same UI with divergent hardcoded values: `.btnAdd` **radius 8px / pad 0.75rem 1.25rem** (primary) vs **radius 6px / pad 0.5rem 1rem** (secondary); national-average marker `.natPill` radius **4px** (primary) vs `.badge`/`.att8` radius **3px** (secondary); section-tab padding `4.8px 10px` vs `4.8px 12px`. Both files hardcode px rather than referencing `--radius-md`.
|
||||||
|
- Criterion violated: Nielsen #4.
|
||||||
|
- Argument: a parent moving from a primary school page to a secondary one (the compare flow explicitly mixes phases) meets the "+ Compare" button and nav tabs rendered at subtly different sizes/corners — the classic "two things that should be one" tax, and double the maintenance surface.
|
||||||
|
- Source verdict: token EXISTS (`--radius-md:8px`) but **bypassed → ENFORCE** (both should use the token; ideally share one component).
|
||||||
|
- Severity: **P2**.
|
||||||
|
|
||||||
|
- **F3. Page-title (H1) sizing is unsystematic across page types**
|
||||||
|
- Evidence: H1 computes to **36px** (rankings, compare), **44px** (admissions), **48px** (home), **52px** (school). Source: each is a separate hardcoded `clamp()`/rem in its own module (home `clamp(2rem,5vw,3.5rem)`; school `clamp(2rem,5vw,3.25rem)`; admissions `2.75rem`); tool pages fall back to smaller local values.
|
||||||
|
- Criterion violated: Nielsen #4 (visual hierarchy consistency).
|
||||||
|
- Argument: page-to-page the "you are here" title jumps size with no rule a user could infer; hero pages don't even agree with each other.
|
||||||
|
- Source verdict: **no shared page-title/hero token → CREATE** (`--title-hero`, `--title-section`) and apply.
|
||||||
|
- Severity: **P2** (hierarchy), leaning P3 in isolation.
|
||||||
|
|
||||||
|
- **F4. Radius scale is bypassed system-wide (15 raw values vs 4 tokens; no pill token)**
|
||||||
|
- Evidence: radius audit above — 3/5/6/9/10/12/14/25px and 999/9999px all appear hardcoded despite `--radius-sm/md/lg/xl`. Same-role badges differ (natPill 4 vs secondary badge 3; ofsted badge 4 vs att8 badge 3).
|
||||||
|
- Criterion violated: Nielsen #4.
|
||||||
|
- Argument: corner rounding is a primary "family resemblance" cue; with 15 values it reads as many hands, not one system.
|
||||||
|
- Source verdict: **mixed** — tokens exist for 4/8/16 (**ENFORCE**); pill radius has **no token → CREATE** `--radius-pill: 999px`.
|
||||||
|
- Severity: **P2**.
|
||||||
|
|
||||||
|
- **F5. "Switch view / phase" control has 3+ different treatments**
|
||||||
|
- Evidence: home search-mode toggle is a **rounded** segmented control (container `--radius-lg`, coral active); rankings & compare phase tabs are **square** (radius 0) bordered coral-fill tabs; admissions sub-nav is an **underline** tab strip (radius 0, teal active). Same job, three shapes and two accent colours.
|
||||||
|
- Criterion violated: Nielsen #4.
|
||||||
|
- Argument: the segmented switch is a recurring interaction; users re-learn it on each page. (Rankings↔compare agreeing is the one bright spot.)
|
||||||
|
- Source verdict: **no shared segmented-control component → CREATE/CONSOLIDATE**.
|
||||||
|
- Severity: **P2**.
|
||||||
|
|
||||||
|
- **F6. Near-duplicate accent colours for the same role**
|
||||||
|
- Evidence: pressed/hover coral is `--accent-coral-dark #c45a3f` on `.btn-primary` but a **hardcoded `#d4654a`** on `.btn-compare:hover` (globals.css:754); gold appears as `#b8920e` on the school-page admissions cross-link badge, matching neither `--accent-gold #c9a227` nor `--accent-gold-text #7a6800`.
|
||||||
|
- Criterion violated: Nielsen #4.
|
||||||
|
- Argument: two hovers for the same "coral button being pressed" is exactly the "two blues doing the same job" consistency defect.
|
||||||
|
- Source verdict: token EXISTS → **ENFORCE** (`--accent-coral-dark`); the off-token gold → ENFORCE `--accent-gold-text`.
|
||||||
|
- Severity: **P3**.
|
||||||
|
|
||||||
|
- **F7. Heading font-family and off-family buttons break the type system locally**
|
||||||
|
- Evidence: on `/admissions` and both school pages, `H3` elements render in **DM Sans /700** while `H2` stays Playfair — an inconsistent semantic-heading treatment. Separately, `/compare` shows 3× `BUTTON` in **Arial 20px** and school pages show Arial/Helvetica-Neue controls.
|
||||||
|
- Criterion violated: Nielsen #4 (the H3 case). The Arial/Helvetica cases are **third-party** (Chart.js legend toggles, Leaflet zoom/attribution) — recorded as environmental, not a first-party fix.
|
||||||
|
- Argument: an H3 in body font reads as a bold paragraph, weakening the Playfair hierarchy the rest of the site sells.
|
||||||
|
- Source verdict: H3 font-family is set locally, no heading-family token discipline → **ENFORCE** Playfair for display headings (or intentionally reclass those H3s as labels).
|
||||||
|
- Severity: **P3**.
|
||||||
|
|
||||||
|
- **F8. School-detail data tables use a smaller type scale than the shared data tables**
|
||||||
|
- Evidence: rankings & compare tables compute **TH 12px / TD 15px**; both school-detail tables compute **TH 11px / TD 13px**.
|
||||||
|
- Criterion violated: Nielsen #4; borderline WCAG 1.4.4 (13px data is small but resizable).
|
||||||
|
- Argument: the same kind of KS2 figures appear one size on compare and a size smaller on the school page — inconsistent, and the smaller variant is the one a parent studies most.
|
||||||
|
- Source verdict: table type is set per-component, **no shared table-type token → CREATE** and apply.
|
||||||
|
- Severity: **P3**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent-additions verdicts (native vs bolted-on)
|
||||||
|
|
||||||
|
- **Map-blended hero (`SchoolHeroMap` on school pages): mostly native, with a third-party seam.** The framing is on-brand — coral back-link, coral "+ Compare", cream surround, Playfair title over the map. But the embedded Leaflet layer imports **off-palette blue `#0078a8` attribution links and Arial zoom controls** straight into the hero (F7), the one place they're most visible. Verdict: **native design, bolted-on furniture** — worth restyling the Leaflet attribution/controls to the palette.
|
||||||
|
- **Characteristic chips (school rows + detail badges): native.** Tints use `--accent-teal-bg`/gold tints that belong to the palette, tone is quiet per the recent commit. Only blemish is radius drift (badge 3px vs natPill 4px, F4) — a token issue, not a stylistic mismatch.
|
||||||
|
- **Admissions cross-links: native in colour, inconsistent in treatment.** The teal `stepTool`/`navLink` links match the accent system, but the *same* "go to a tool" intent is a plain underlined **text link** on `/admissions` yet a **gold badge (`#b8920e`)** on the school page (F6) — and journey-5 already logged one such cross-link at a 39px tap target. Verdict: **native palette, slightly bolted-on** because the cross-link component isn't unified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- **Header + footer are pixel-identical on all six pages** (logo Playfair, coral nav-active tint, dark footer with faded links) — the strongest cohesion anchor on the site.
|
||||||
|
- **Coral = primary / teal = secondary** is held consistently for button and link roles (no role/colour collisions).
|
||||||
|
- **Rankings and compare phase tabs are genuinely shared** (radius 0, pad 10×24, coral active) — the model for what F5 should become everywhere.
|
||||||
|
- **Both school-detail H1s agree at 52px**, and the deadline countdown chip is byte-for-byte identical between the homepage widget and `/admissions` (radius 12px, pad 16px 17.6px 14.4px) — a correctly reused component.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- No taste-only findings: every F cites either a computed-style diff (F1/F2/F3/F4/F6/F7/F8 all carry live px/hex) or a shared-component absence (F5), plus a criterion.
|
||||||
|
- Token-vs-no-token recorded for every source-checked finding: **ENFORCE** — F1, F2, F4(4/8/16), F6, F7; **CREATE** — F3 (hero title token), F4 (pill radius token), F5 (segmented-control component), F8 (table-type token).
|
||||||
|
- Third-party styling (Chart.js Arial, Leaflet blue/Arial) is explicitly separated from first-party defects rather than filed as findings.
|
||||||
|
- Severity spread: **P2 ×5** (F1, F2, F3, F4, F5), **P3 ×3** (F6, F7, F8). No P0/P1 — nothing blocks a task; the journey-5 contrast WCAG failure is already logged there and not re-filed here.
|
||||||
|
|
||||||
|
## Screenshots (referenced, not committed)
|
||||||
|
- `cohesion-compare-3school-mobile.jpeg`: `/compare` at 390×844 with three same-phase primary schools — cards stack, chart renders, table scrolls within its container.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Journey 1: Home → find my school — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** `/`, `/?search=Welland+Primary` (results), `/?search=B91+3` (postcode results), `/school/146678-welland-primary-school`
|
||||||
|
**Viewports:** 390×844 (primary), 1440×900 (+720×450 for 200% reflow)
|
||||||
|
**Site:** live https://schoolcompare.co.uk only. Read-only: searched/filtered, submitted no data-modifying forms.
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
**Mobile (390×844) — Attempt A: find a school by name.**
|
||||||
|
- Home loads with search input above the fold (input top 176px / bottom 246px of an 844px viewport, 70px tall — comfortable tap target). H1 "Every school in England, *compared.*" at 85px. `Schools near me` button at ~388px. Full page is 3132px tall; content order is search → near-me → admissions-deadline rail → "Start exploring" links → marketing ("What you'll see", "About school data") → footer. **Primary task is first — good.**
|
||||||
|
- Typed `Welland Primary` (15 keystrokes). **No live autocomplete/typeahead appeared** while typing.
|
||||||
|
- Tapped `Search`. URL → `/?search=Welland+Primary`. Result was instant (no perceptible wait; no spinner needed). "1 school found", correct school ranked first, rich card: `Good · 2023`, Primary, Academy converter, Ages 4–11, 74% RWM with down-trend arrow (prev 76%), +12 pts vs national, 131 pupils, Worcestershire.
|
||||||
|
- Tapped `View` → `/school/146678-welland-primary-school`, instant.
|
||||||
|
- **Time-to-school-page: 3 taps (searchbox, Search, View) + typing, effectively instant load. Well under the ~15s target.**
|
||||||
|
|
||||||
|
**Mobile — Attempt B: find schools by postcode `B91 3` (Solihull).**
|
||||||
|
- Same 3-interaction pattern (focus box, type, Enter). URL → `/?search=B91+3`. "8 schools found."
|
||||||
|
- **Results show NO distance and are NOT sorted by proximity** (sort = "Relevance"). The list mixes phases and the **top result is a Secondary school** (Tudor Grange Academy), followed by an independent all-through, another secondary, then primaries, and a Sixth Form College last. Behaviour is consistent with a postcode-prefix text match against the postcode column, not a geographic radius search.
|
||||||
|
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||||
|
|
||||||
|
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||||
|
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||||
|
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||||
|
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||||
|
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. Search has no autocomplete / typeahead suggestions**
|
||||||
|
- Evidence: Typing `Welland Primary` (mobile & desktop) produced no suggestion dropdown at any keystroke; snapshots show only the raw input until submit. Screenshot `j1-home-mobile-fold.png`.
|
||||||
|
- Criterion violated: Nielsen #6 (Recognition rather than recall) + established site-search usability (NN-g "search suggestions"); WCAG-adjacent error-prevention.
|
||||||
|
- Argument: Parents frequently don't know a school's exact registered name ("Welland" vs "Welland Church of England"). With no suggestions and an exact-ish match required, a misspelling risks a zero-result dead end on the highest-traffic path, and every search costs full typing + a blind submit.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F2. Postcode search shows no distance and isn't sorted by proximity** — **⚠ REVISED after recheck, see addendum at end of file: this holds only for PARTIAL postcodes; a full postcode triggers a working proximity search with distances.**
|
||||||
|
- Evidence: `/?search=B91+3` → "8 schools found", sort "Relevance", top result a Secondary school, phases mixed (secondary/independent/primary/sixth-form), zero distance shown on any card. Screenshot `j1-postcode-results-mobile.png`.
|
||||||
|
- Criterion violated: Nielsen #2 (Match between system and the real world) + task-flow obstruction. The placeholder invites a "postcode", which sets an expectation of "nearest schools first"; the delivered value (proximity ordering + distance) is missing.
|
||||||
|
- Argument: On the core "find my school near me" task, a parent who types their postcode expects the closest schools ranked by distance. Instead they get an unordered, distance-less, mixed-phase list led by a secondary school — it reads as broken and gives no way to judge "which is closest," pushing them to abandon. (Name search works, so the overall journey isn't fully blocked → P1 not P0, but this is the weakest link on the primary task.)
|
||||||
|
- Severity guess: P1 (candidate P0 for the postcode sub-path)
|
||||||
|
|
||||||
|
- **F3. Filter and Sort dropdowns on the results view have no accessible name**
|
||||||
|
- Evidence: axe `select-name` (impact **critical**, 2 nodes) on `/?search=Welland+Primary`; targets `.FilterBar-module…controlSelect` (phase filter) and `.HomeView-module…sortSelect`.
|
||||||
|
- Criterion violated: WCAG 2.2 **4.1.2 Name, Role, Value (Level A)**.
|
||||||
|
- Argument: These are the exact controls a parent uses to narrow a mixed result set to "Primary" or re-sort — and a screen-reader user hears an unlabelled combobox, so cannot tell what either does. This is on the main journey's results view (same `/` route), a clear Level-A failure.
|
||||||
|
- Severity guess: P1 (candidate P0 — WCAG A failure on the main route)
|
||||||
|
|
||||||
|
- **F4. Serious colour-contrast failures on the home page**
|
||||||
|
- Evidence: axe `color-contrast` (impact serious) — 6 nodes on home (`/`), incl. the active nav tab label, `.btn`, and "how-it-works" step text; 3 nodes on results (incl. `.btn-primary`, Ofsted badge).
|
||||||
|
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
|
||||||
|
- Argument: Low-contrast labels and buttons are harder to read for low-vision parents and for everyone on a phone in daylight — the mobile-primary audience. Affects the main CTA styling and the active-tab indicator.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F5. Admissions-deadline rail: keyboard-inaccessible scroll region, defaults to the least-urgent card**
|
||||||
|
- Evidence: axe `scrollable-region-focusable` (serious, 1 node) on `.countdownRail`; on mobile the rail's default scroll position lands on the 4th card ("Primary Offer Day · 288 days") rather than the nearest deadline ("Secondary · 121 days"), with only a partial card peeking as the scroll cue. Screenshots `j1-home-mobile-fold.png`, `j1-home-mobile-full.png`.
|
||||||
|
- Criterion violated: WCAG 2.2 **2.1.1 Keyboard (A)** for the scroll region; Nielsen #1 (Visibility of system status) for the ordering.
|
||||||
|
- Argument: Keyboard/switch users can't reach the later cards, and the odd default scroll buries the single most time-critical deadline (121 days) behind less urgent ones — the opposite of what an anxious parent needs surfaced.
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||||
|
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||||
|
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||||
|
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- **Name search is genuinely excellent.** `Welland Primary` returned the correct school as the sole top result with an information-rich card (Ofsted grade+year, RWM % with year-over-year trend arrow, "+12 pts vs national", pupil count, LA) — 3 taps + typing, instant load, comfortably under the 15s target. This is the journey's strongest asset.
|
||||||
|
- **Search is the unmistakable primary action.** Prominent, above the fold on both viewports, large (70px) tap target, plain-English placeholder "School name or postcode", single clear "Search" CTA; content order puts the task first.
|
||||||
|
- **Accessibility fundamentals partly in place:** a working "Skip to main content" link (keyboard-focusable, visible outline), logical tab order, and strong 2px focus outlines on nav/skip/button.
|
||||||
|
- **Robust responsive reflow:** 200%/720px shows no horizontal scroll and no overlap; deadline cards reflow cleanly.
|
||||||
|
- **Result cards adapt to phase** (Attainment 8 for secondary, RWM % for primary) — good match to the real-world data a parent expects for each phase.
|
||||||
|
- **A real proximity path exists** via the "Schools near me" geolocation button (distinct teal styling) — the raw material for good location search is present, just not wired to the postcode text input (see F2).
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
- `/` (home, initial) @ 390×844: **2 violations** — `color-contrast` (serious, 6 nodes), `scrollable-region-focusable` (serious, 1 node).
|
||||||
|
- `/?search=Welland+Primary` (results) @ 390×844: **2 violations** — `color-contrast` (serious, 3 nodes), `select-name` (**critical**, 2 nodes).
|
||||||
|
- Desktop (1440×900) serves the same DOM/CSS; violations above apply equally (not separately re-scanned).
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
- **Touch targets ≥24px:** Pass on the core path — search input 70px tall, Search button large, "Schools near me" 40px, result-card `View`/`+ Compare` full-size. Watch: the small "×" chip on the active-search token is borderline.
|
||||||
|
- **Keyboard: tab order, focus visibility (desktop):** Pass with a caveat — order is logical and skip/nav/buttons show a clear 2px orange outline; the search **input** relies on an orange border + a very faint 0.12-alpha ring (visible but the weakest indicator on the page — borderline for 2.4.11 Focus Appearance).
|
||||||
|
- **Zoom 200% text reflow (desktop):** Pass — no horizontal scroll at 720px, no clipping/overlap.
|
||||||
|
|
||||||
|
## Why do 46% of visitors exit at home? — observed plausible causes
|
||||||
|
1. **Postcode expectation mismatch (F2).** Many parents will type their postcode expecting "nearest schools, closest first." They get a distance-less, unsorted, mixed-phase list led by a secondary school — it looks broken, so they leave. This is the biggest task-level leak on the primary journey.
|
||||||
|
2. **Weak mobile value proposition (F6).** On the 63%-of-entries mobile hero, the vague H1 + bare search box give a first-time visitor no reason to trust or continue; no coverage/freshness/data statement above the fold. Classic bounce driver.
|
||||||
|
3. **No search assistance (F1).** Typing a school name with zero suggestions and requiring a near-exact match means a spelling slip → likely a zero-result dead end → exit.
|
||||||
|
4. **Trust/polish erosion (F3, F4).** Critical unlabelled controls and serious contrast failures degrade the "credible data source" impression, especially for the accessibility-dependent and daylight-mobile segments.
|
||||||
|
5. **Benign exits (nuance for synthesis).** Some "home exits" are successes, not failures: a user who finds their school card and taps an outbound link, or who reads the answer and leaves satisfied. Not the entire 46% is a usability leak — but causes 1–4 are the addressable share.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
- `j1-home-mobile-fold.png`: mobile home above the fold — H1 + prominent search box + Search CTA, deadline rail beginning.
|
||||||
|
- `j1-home-mobile-full.png`: full mobile home — content order (search → near-me → deadlines → explore → marketing → footer).
|
||||||
|
- `j1-search-results-mobile.png`: mobile results for "Welland Primary" — correct single result, rich card.
|
||||||
|
- `j1-postcode-results-mobile.png`: mobile results for "B91 3" — no distance, relevance sort, secondary school first.
|
||||||
|
- `j1-home-desktop-fold.png`: desktop home — includes trust badge + value-prop subheading absent on mobile.
|
||||||
|
- `j1-search-results-desktop.png`: desktop results for "Welland Primary".
|
||||||
|
- `j1-focus-searchbox-desktop.png`: keyboard focus state on the search input (orange border + faint ring).
|
||||||
|
- `j1-home-zoom200-desktop.png`: full home at 720px (≈200% reflow) — no horizontal scroll, cards reflow to 2×2.
|
||||||
|
- `j1-fullpostcode-results-mobile.png` (recheck): mobile results for full postcode "B91 3DL" — "13 schools within 1.0 miles", distance on every card, nearest-first.
|
||||||
|
|
||||||
|
## Recheck addendum (2026-07-02) — F2 tested with a FULL postcode
|
||||||
|
|
||||||
|
The original Attempt B used the partial postcode `B91 3`. Rechecked with the full postcode **`B91 3DL`** typed into the home search box and submitted:
|
||||||
|
|
||||||
|
- The submit handler (`FilterBar.tsx` `isValidPostcode()`, full-postcode regex `/^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i`) recognised it and routed to **`/?postcode=B91+3DL&radius=1`** — the geocoded radius search, not text search.
|
||||||
|
- Result: heading **"13 schools within 1.0 miles of B91 3DL"**, **every card shows distance** ("0.1 mi", "0.2 mi" … "1.0 mi"), **sorted nearest-first**, plus a "Within: 0.5/1/3/5 miles" radius selector, a List/Map toggle, and a "Nearest first" sort option. Screenshot `j1-fullpostcode-results-mobile.png`. This is exactly the experience F2 asked for — it exists and works well.
|
||||||
|
- **F2 as originally stated is therefore wrong for full postcodes.** The residual, real finding is narrower: input that *looks like* a location but fails the full-postcode regex — outcodes ("B91"), partials ("B91 3"), postcodes with typos — **silently falls back to name/address text search** (`/?search=…`) with no distances, relevance ordering, and no notice that a full postcode would unlock proximity search. The two result pages look similar enough that a parent won't know which mode they're in. Severity re-guessed at P1→P2 (degraded sub-path with a working primary path and a working "Schools near me" alternative; the failure is silent-mode-switching, not a missing capability).
|
||||||
|
- Exit-cause list item 1 ("postcode expectation mismatch") should be read with this narrower scope: it applies to partial/malformed postcode input only.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Journey 2: Cold landing on a school detail page (SEO long tail) — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** `/school/136916-the-castle-school` (secondary), `/school/146678-welland-primary-school` (primary), `/compare` (reached via the compare CTA)
|
||||||
|
**Viewports:** 390×844 (primary), 1440×900 (+720×450 for 200% reflow)
|
||||||
|
**Site:** live https://schoolcompare.co.uk only. Read-only: navigated, tapped "Add to Compare", switched chart tabs; submitted no data-modifying forms.
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
**Cold-landing premise:** each school URL was the *first* navigation into the site (simulating a Google arrival), so first-screen orientation is judged with zero prior context.
|
||||||
|
|
||||||
|
**Mobile (390×844) — Cold landing A: The Castle School (secondary, Somerset).**
|
||||||
|
- Above the fold, top→bottom: header (logo + Search/Compare/Rankings/Admissions), "← Back", a **Leaflet map**, then the school identity block. **The single most important element — the H1 "The Castle School" — is crushed between two overlapping elements:** the map (bbox bottom 280px) sits over the top of the H1 (top 272px) and the floating **"+ Add to Compare"** button (top 286px) sits over its lower half (H1 bottom 309px). Only a ~6px band of the name is unobscured. Screenshot `j2-school-mobile-fold.png` shows the name almost illegible. So the answer to "what school is this?" — the first cold-landing question — is broken on the primary viewport.
|
||||||
|
- "What site is this?" is answerable (SchoolCompare logo + bottom tab bar). "What does the site offer?" is *not* stated on the school page — no value proposition or "compare schools" framing above the fold.
|
||||||
|
- Scrolled the full 3,748px page. Section order: Ofsted → GCSE Results (2024/25) → Admissions → Historical Results → Wellbeing & Context → footer. Screenshots `j2-school-mobile-full.png`, `j2-school-mobile-gcse.png`, `j2-school-mobile-history.png`.
|
||||||
|
- **Ofsted:** clear ("Outstanding"), dated (Inspected 3 Oct 2023), links to the real Ofsted report, and includes a current-aware note about the post-Sept-2024 grading change. Comprehensible.
|
||||||
|
- **GCSE:** rich and comparison-anchored (Attainment 8 53.4 / National avg 39.1 / "+14 pts"), with a plain caption and a "treat Progress 8 with caution" note. **But the jargon is unexplained in place:** "Attainment 8", "Progress 8", "EBacc average point score 4.72" have no tooltip/expander/definition (0 info affordances found in DOM). The plain-English definition *exists on the site* ("Average grade across a pupil's best 8 GCSEs including English and Maths") but only appears on `/compare`, not where a parent first meets the term.
|
||||||
|
- **Historical chart:** renders on scroll (the blank in the full-page capture was a lazy-render artifact, not a bug). Series = solid teal (school) + grey dashed (national) with **no visible legend**, and a data gap 2018/19→2023/24 is bridged by the line.
|
||||||
|
- **Admissions:** legible; surfaces the decision-critical "⚠ Applications exceeded places last year" and honestly states cut-off data is unavailable.
|
||||||
|
- **Next-step paths.** "+ Add to Compare" exists (overlapping the H1). There is **no "schools near this one" / "similar schools" module anywhere on the page** — the page ends at Wellbeing → footer.
|
||||||
|
- **Taps to a comparison including this school:** Tap 1 = "+ Add to Compare" (button → "✓ In Comparison", bottom Compare tab shows a "1" badge; no toast). Tap 2 = Compare tab → `/compare?urns=136916`, "Comparing 1 school". Screenshots `j2-school-mobile-compare-added.png`, `j2-compare-oneschool-mobile.png`. So **2 taps to a compare *view* containing the school, but a real 2-school comparison requires "+ Add School" then a manual name search** — because no nearby/similar list is offered, the parent must already know the competitor's name.
|
||||||
|
|
||||||
|
**Mobile — Cold landing B: Welland Primary School (primary, Worcestershire) — contrasting data.**
|
||||||
|
- **Same H1-overlap bug recurs** (map bottom 264 > H1 top 256; "+ Add to Compare" top 270 < H1 bottom 278) — confirming it is *systemic to the school template*, not school-specific. Screenshot `j2-welland-mobile-full.png`.
|
||||||
|
- Primary (KS2) page handles the same data class **markedly better**: caption "End-of-primary-school tests taken by Year 6 pupils"; a standout in-place explainer — *"Why is combined lower? A pupil is only counted if they met the bar in all three subjects…"* — which answers a real parent question without leaving the page; subject bar charts carry a legend (Expected standard / Exceeding / National average); "Pupil Premium 26.0% — Pupils from disadvantaged backgrounds" is expanded.
|
||||||
|
- **Missing-data handling is graceful:** "*No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19*" and "Historical distance cut-off data is not available… Contact the admissions authority." No broken/empty UI. No layout breakage beyond the shared H1 overlap.
|
||||||
|
|
||||||
|
**Desktop (1440×900) — reload of The Castle School.**
|
||||||
|
- Fold is clean (`j2-school-desktop-fold.png`): H1 large and legible (the map's ~8px overlap is imperceptible with the compare button moved to the top-right), full section-nav pills visible (Top/Ofsted/GCSEs/Admissions/History/Wellbeing), and a persistent **"1 school selected" compare tray** at bottom-centre (a desktop-only affordance mobile lacks).
|
||||||
|
- Chart legibility: bars and axis labels are clear; the "Nat avg 39.1" badge overlaps the chart *heading* text (minor).
|
||||||
|
- Keyboard: logical order (skip → logo → nav → Back → …), every stop shows a **2px solid orange (#E07256) focus outline**, including the in-`main` "← Back" button.
|
||||||
|
- Link affordance: in-content links (School website, Ofsted reports) are teal with **no underline** — distinguished from body text by colour plus an "↗" glyph for external links; "View on map" is an orange button. Clickable-looking to sighted users but colour-dependent.
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. School name (H1) is overlapped/obscured by the map and the "Add to Compare" button on mobile — systemic**
|
||||||
|
- Evidence: Measured bboxes on both schools — Castle: map bottom 280 / H1 top 272 / compare-btn top 286 / H1 bottom 309; Welland: map bottom 264 / H1 top 256 / compare-btn top 270 / H1 bottom 278. Screenshots `j2-school-mobile-fold.png`, `j2-welland-mobile-full.png`.
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status) & #8 (Aesthetic & minimalist design); task-flow obstruction of cold-landing orientation; overlap defeats WCAG 1.4.10 Reflow's intent (content should not be obscured on the primary viewport).
|
||||||
|
- Argument: On a Google cold landing the very first question is "is this the school I searched for?" The H1 that answers it is crushed to a ~6px sliver between the map and a CTA on the 390px viewport — the exact first-screen orientation failure that drives immediate back-to-search bounces, on the site's highest-traffic SEO template.
|
||||||
|
- Severity guess: P1 (candidate P0 — primary content, main template, primary viewport)
|
||||||
|
|
||||||
|
- **F2. No "schools near this one" / similar-schools path — the compare value prop has no on-ramp**
|
||||||
|
- Evidence: Full-page snapshots of both schools show sections Ofsted→GCSE/KS2→Admissions→History→Wellbeing→footer with **no nearby/similar-schools module**. Reaching a 2-school comparison requires Compare → "+ Add School" → manual name search (`j2-compare-oneschool-mobile.png`).
|
||||||
|
- Criterion violated: Nielsen #7 (Flexibility & efficiency of use); task-flow obstruction — the site's core differentiator ("compare") is unreachable from the highest-traffic entry point without prior knowledge.
|
||||||
|
- Argument: A parent landing cold on one school has nothing to weigh it against and no way to discover the alternatives they came to compare; the compare tool assumes they already know competitor names. At the moment of maximum intent, the site offers no lateral discovery, so the parent bounces back to Google to find other local schools.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F3. GCSE jargon (Attainment 8, Progress 8, EBacc) unexplained in place on the secondary school page**
|
||||||
|
- Evidence: GCSE section shows "Attainment 8 score 53.4", "EBacc average point score 4.72" with national averages but no definitions; 0 tooltip/info affordances in the DOM. The plain-English definition exists only on `/compare` ("Average grade across a pupil's best 8 GCSEs including English and Maths"). Screenshot `j2-school-mobile-gcse.png`.
|
||||||
|
- Criterion violated: Nielsen #2 (Match between system and the real world) & #10 (Help & documentation).
|
||||||
|
- Argument: A non-specialist parent cannot judge whether "53.4" or "4.72" is good without knowing the scale/meaning. The site *has* the explanation but withholds it at first contact, forcing recall or a bounce. The national-average anchoring softens this but does not define the metric. (The primary page does this far better — see Works well.)
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F4. Serious colour-contrast failures on the school template**
|
||||||
|
- Evidence: axe `color-contrast` (serious, **13 nodes**) on the Castle page — sample targets `…backBtn` ("← Back"), `…mapLink` ("View on map"), `…tabBtnActive` (active metric tab). Recurs from Journey 1 F4 (also seen on home).
|
||||||
|
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
|
||||||
|
- Argument: Low-contrast Back, "View on map" and active-tab text are harder to read for low-vision parents and for everyone on a phone in daylight — the mobile-primary audience — on the main content template.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F5. Trend/results charts expose no text alternative to assistive tech**
|
||||||
|
- Evidence: axe `role-img-alt` (serious) on the `<canvas>` chart on **both** pages (Castle GCSE trend; Welland KS2 chart `canvas[height="220"]`). WCAG-mapped tag failure.
|
||||||
|
- Criterion violated: WCAG 2.2 **1.1.1 Non-text Content (A)**.
|
||||||
|
- Argument: The key data visualisation — performance over time — is invisible to a screen-reader parent; they get no equivalent from the chart. A "View raw year-by-year data" expander partially mitigates the trend chart, but the canvas itself still announces nothing. Level-A failure on the main template.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F6. Chart interpretation aids are weak: no legend for the national-average series; label overlap**
|
||||||
|
- Evidence: Historical trend chart shows a grey dashed line (national) with no visible legend (`j2-school-mobile-history.png`); the "Nat avg 39.1" badge overlaps the "ATTAINMENT 8 — SCHOOL VS NATIONAL" heading (`j2-school-mobile-gcse.png`).
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status) & #8 (Aesthetic & minimalist); task-flow (data interpretation).
|
||||||
|
- Argument: A non-specialist can't reliably tell what the second (dashed) line represents without a legend, undermining the core "vs national" comparison; the badge/heading overlap erodes the polish that signals a trustworthy data source. (Notably, the *primary* subject charts DO carry a legend — inconsistent.)
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F7. Sticky in-page section nav clips its last items on mobile with no scroll affordance**
|
||||||
|
- Evidence: On 390px, "History" (right edge 397px) and "Wellbeing" (right 480px) render off-screen; the nav row is 448px wide and is not horizontally scrollable (document scrollWidth stays 390 — clipped, not scrollable). Screenshot `j2-school-mobile-gcse.png` (nav shows "…Admissions Histo").
|
||||||
|
- Criterion violated: Mobile usability (interactive content must be reachable within the viewport); Nielsen #7 (Flexibility & efficiency).
|
||||||
|
- Argument: 2 of 6 in-page jump links are unreachable via the sticky nav on the primary viewport, so a parent can't quickly jump to the SEN/Wellbeing or History data — they must hunt by scrolling, weakening the nav's purpose. (Content is still reachable by scrolling, so not a hard block.)
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F8. "Add to Compare" confirmation is easy to miss**
|
||||||
|
- Evidence: After tapping, no toast/live-region message fired (`role=alert`/`role=status` empty); feedback is only the button relabel ("✓ In Comparison") — which sits in the overlapped/obscured H1 zone (F1) — plus a small "1" badge on the bottom Compare tab.
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status).
|
||||||
|
- Argument: The relabel is reasonable feedback, but because it lands in the visually crowded overlap area and there's no explicit confirmation or "view your shortlist" nudge, a parent may not register that the action succeeded or know where the shortlist lives.
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- **Strong identity + facts block (when not overlapped).** Both pages lead with a located map plus address, headteacher, official school website, MAT/trust, and pupils-vs-capacity — exactly the orientation facts a cold visitor needs. The raw material is excellent; only the F1 overlap spoils it on mobile.
|
||||||
|
- **The primary (KS2) page explains its data in plain English.** Caption "End-of-primary-school tests taken by Year 6 pupils"; the "Why is combined lower?" explainer answers a genuine parent question *in place*; subject charts carry a legend. This is the model the secondary page (F3/F6) should follow.
|
||||||
|
- **Everything is anchored to the national average** ("+14 pts", "National avg 39.1", "National avg 62%") — lets a non-specialist judge good/bad without leaving the page.
|
||||||
|
- **Graceful, honest missing-data handling.** "No data for 2019/20 or 2020/21 — national assessments cancelled due to COVID-19"; "Historical distance cut-off data is not available… contact the admissions authority." No broken or empty UI where data is absent.
|
||||||
|
- **Ofsted section is clear, current, and trustworthy.** Grade + inspection date, the post-Sept-2024 grading-change note, and a link to the actual Ofsted report.
|
||||||
|
- **Decision-critical admissions fact is surfaced** ("⚠ Applications exceeded places last year") rather than buried in a table.
|
||||||
|
- **Desktop is clean and accessible.** No H1 overlap, full section nav visible, persistent compare tray, legible charts; logical keyboard order with a visible 2px orange focus outline on every control incl. the in-`main` Back button; 200% reflow (720px) has no horizontal scroll.
|
||||||
|
- **Compare metric definitions exist** (on `/compare`) and the shortlist persists via localStorage — the explanatory content is written, just not surfaced on the detail page.
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
- `/school/136916-the-castle-school` @ 390×844 & 1440×900 (same DOM/CSS): **2 violations** — `color-contrast` (serious, 13 nodes: backBtn, mapLink, tabBtnActive), `role-img-alt` (serious, 1 node: chart `canvas`).
|
||||||
|
- `/school/146678-welland-primary-school` @ 390×844: **1 violation** — `role-img-alt` (serious, 1 node: `canvas[height="220"]` KS2 chart). No `color-contrast` violation was reported on this page's rendered DOM.
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
- **Touch targets ≥24px:** Pass (marginal). "← Back" button measures 64×28px (meets the 24px minimum but is the smallest); "+ Add to Compare", metric tabs and section-nav pills are comfortably sized. WCAG 2.5.8 Target Size (Minimum) met.
|
||||||
|
- **Keyboard: tab order, focus visibility (desktop):** Pass — order is logical (skip → logo → nav → Back → content), and every stop including the in-`main` Back button shows a clear 2px solid orange (#E07256) outline.
|
||||||
|
- **Zoom 200% text reflow (desktop):** Pass — at 720px width the school page has no horizontal scroll (scrollWidth == clientWidth == 720).
|
||||||
|
|
||||||
|
## "Parent lands here from Google — what makes them stay vs bounce?"
|
||||||
|
**Would stay because:** the page answers the real questions — Ofsted grade (with a link to the source and an up-to-date grading note), results anchored to the national average, admissions pressure ("applications exceeded places"), and — on primary — plain-English explanations and honest COVID/data-gap handling. That is a credible, decision-useful page.
|
||||||
|
**Would bounce because:** (1) on the primary mobile viewport the **school name itself is obscured** by the map + compare button (F1), so the first "is this the right school?" glance fails; (2) there is **no way to discover or reach comparable nearby schools** (F2) — the site's whole reason to exist is invisible from its highest-traffic entry point, so a parent leaves to find alternatives elsewhere; (3) on secondary pages the **key numbers are jargon** with no in-place definition (F3/F6), so a non-specialist can't interpret them.
|
||||||
|
**Highest-leverage fixes:** un-overlap the H1 (F1); add a "Nearby / similar schools — add to compare" module to the detail page (F2); reuse the existing `/compare` metric definitions as in-place tooltips/captions on the detail page (F3/F6).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
- `j2-school-mobile-fold.png`: Castle mobile first screen — H1 crushed between map and "+ Add to Compare".
|
||||||
|
- `j2-school-mobile-full.png`: full Castle mobile page — section order Ofsted→GCSE→Admissions→History→Wellbeing.
|
||||||
|
- `j2-school-mobile-gcse.png`: Castle GCSE section — jargon without definitions; "Nat avg 39.1" badge overlapping the chart heading; section nav clipped ("Histo").
|
||||||
|
- `j2-school-mobile-history.png`: Castle trend chart — national series is an unlabelled grey dashed line; data gap bridged.
|
||||||
|
- `j2-school-mobile-compare-added.png`: after "Add to Compare" — "✓ In Comparison" + bottom-tab "1" badge, no toast; button still over the H1.
|
||||||
|
- `j2-compare-oneschool-mobile.png`: `/compare` with one school — where metric definitions (Attainment 8) actually appear.
|
||||||
|
- `j2-welland-mobile-full.png`: Welland (primary) full page — same H1 overlap; strong in-place KS2 explainers and graceful missing-data handling.
|
||||||
|
- `j2-school-desktop-fold.png`: Castle desktop fold — clean H1, full section nav, persistent compare tray.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Journey 3: Building a comparison (`/compare`) — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** `/compare` (empty state, direct entry), `/compare?urns=…` (populated, 1–3 schools), add-school modal, `/school/142161-…` (persistence round-trip)
|
||||||
|
**Viewports:** 390×844 (primary), 1440×900 (+720×450 proxy for 200% reflow)
|
||||||
|
**Site:** live https://schoolcompare.co.uk only. Read-only: searched, added/removed schools in the compare basket (client/localStorage + URL state), opened/closed the modal, hovered the chart. Submitted no data-modifying forms. `selectedSchools` was cleared from localStorage only to reproduce a genuine empty state and to test the share-link defect.
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
**Direct entry premise:** 20% of sessions enter on `/compare`, so the empty state is judged as a cold arrival.
|
||||||
|
|
||||||
|
**Mobile (390×844) — empty state.**
|
||||||
|
- Navigating to `/compare` directly first showed a *persisted* prior selection (The Castle School, carried in `localStorage.selectedSchools` from an earlier session) — the URL self-rehydrated to `?urns=136916&metric=…`. This is persistence working (see Works well) but means the "empty" state is only seen by first-ever visitors. Cleared storage to capture the true empty state (`j3-compare-empty-mobile.png`).
|
||||||
|
- Empty state is **instructive and actionable**: heading "No schools selected", body "Add schools from the home page or search to start comparing", and a primary button **"+ Add Schools to Compare"** that opens an in-page search modal. A cold arrival can start without leaving the page.
|
||||||
|
|
||||||
|
**Mobile — building a two-school comparison. Taps counted from empty state:**
|
||||||
|
1. Tap **"+ Add Schools to Compare"** → opens modal, auto-focuses the "School name or postcode" search box.
|
||||||
|
2. **Type** "St Mary" → live result list appears (name + LA + school type + "+ Compare" per row).
|
||||||
|
3. Tap **"+ Compare"** on a result → school added; **modal stays open, field clears** ("Comparing 1 school" behind it).
|
||||||
|
4. **Type** "Ottery St Mary" → result list.
|
||||||
|
5. Tap **"+ Compare"** → second school added (`?urns=142161,113105`).
|
||||||
|
6. Tap **"Close modal"** → reveals the comparison (`j3-compare-two-schools-mobile.png`).
|
||||||
|
|
||||||
|
→ **6 distinct interactions (4 taps + 2 text-entry sequences)** to a two-school comparison. Critically, both schools had to be **found by name/postcode** — there is no browse / "schools near this one" / "similar schools" on-ramp inside compare (ties to Journey 2 F2). A parent who knows only one school name cannot build a comparison here.
|
||||||
|
|
||||||
|
**Mobile — comparison output assessment.**
|
||||||
|
- Per-metric **school cards stack vertically** and are fully legible; each shows name, LA, type, the selected metric value (e.g. 77.0% vs 51.0%), colour-keyed to the chart. Good on 390px.
|
||||||
|
- **Chart** ("Performance Over Time"): renders on real scroll (blank in full-page captures = lazy-render artifact, confirmed by element screenshot `j3-compare-chart-mobile.png`). Legend labels each line by **colour + full school name** — colour is not the sole differentiator (WCAG 1.4.1 satisfied). `canvas` has `role="img"` but **`aria-label` = null** (no text alt).
|
||||||
|
- **"Detailed Comparison" table**: wrapper 626px inside a 324px column → only **Year + the first school column fit**; the second school is off-screen behind horizontal scroll (`overflow-x:auto`, no visible scroll affordance). The whole point of the table (side-by-side) can't be seen at once on the primary viewport.
|
||||||
|
- Metric selector carries a **plain-English caption** ("% meeting expected standard in reading, writing and maths") — jargon defined in place (the thing school-detail pages lacked, Journey 2 F3).
|
||||||
|
|
||||||
|
**Mobile — remove / add / persistence.**
|
||||||
|
- **Remove (×)** is immediate: no confirm, no toast, no undo; URL updates instantly.
|
||||||
|
- **Postcode search works**: "TA1 5AU" → The Castle School (advertised feature, functional).
|
||||||
|
- **Add beyond two / phase mixing**: adding a secondary school to a primary comparison keeps the header at "Comparing 2 schools" but renders **only the primary card**; the secondary sits hidden behind a "Secondary (1)" tab with no explanation.
|
||||||
|
- **Persistence**: navigated to a school detail page, then back to bare `/compare` — both schools restored and URL rehydrated to `?urns=142161,136916`. State survives real navigation via localStorage; comparisons are URL-encoded / deep-linkable.
|
||||||
|
|
||||||
|
**Desktop (1440×900).**
|
||||||
|
- Table fits with **both/all school columns visible** (no page overflow). Layout clean (`j3-compare-two-schools-desktop.png`).
|
||||||
|
- **Keyboard add/remove**: "+ Add School" reachable and Enter-activatable; modal **opens with focus moved into the search input**; Tab reaches a result's "+ Compare" with a visible **2px solid orange (#E07256)** focus outline; Enter adds it. **But** after adding, focus **drops to `document.body`**; after **Escape** (which does close the modal) focus is again on `body`, **not** returned to the "+ Add School" trigger.
|
||||||
|
- Modal has **no `role="dialog"` and no `aria-modal`** — not announced as a dialog.
|
||||||
|
- **Chart tooltip** (`j3-compare-chart-tooltip-desktop.png`): hover shows year + per-school values with colour swatch + name — **mouse-only** (no touch/keyboard equivalent), but the same values are present as text in the table below.
|
||||||
|
- **200% zoom (720×450 proxy)**: no page-level horizontal overflow — content reflows (WCAG 1.4.10 pass); the detailed table retains its own contained horizontal scroll.
|
||||||
|
|
||||||
|
### Is 32% of exits task-complete satisfaction or abandonment?
|
||||||
|
**Mixed, with concrete abandonment drivers — it is not safe to read the exits as pure satisfaction.**
|
||||||
|
- *Points to satisfaction:* compare is a terminal "results" tool — the natural next step (visit/apply) is off-site, so a high exit share is partly expected; persistence + shareable URLs mean some exits are "saved for later"; the core output (cards + chart + table) renders and metrics are explained.
|
||||||
|
- *Points to abandonment:* (a) **20% ENTER on `/compare`**, often via a shared link — and the **localStorage-overrides-URL defect (F4)** silently shows a returning recipient *their own* schools instead of the shared ones, a confusing dead end; (b) a 2-school comparison needs 6 interactions **and prior knowledge of both school names** (no discovery on-ramp) — a parent with one school hits a wall; (c) on mobile the **detailed table hides the second school off-screen** behind an unaffordanced scroll (F1), so the comparison can look incomplete; (d) low-contrast primary buttons (F6) degrade the mobile-daylight path. F1/F4/F6 are each capable of turning an intended task-complete exit into premature abandonment, especially for the 20% arriving on shared links.
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. "Detailed Comparison" table hides the second/third school off-screen on mobile**
|
||||||
|
- Evidence: at 390px the table is 626px inside a 324px `.tableWrapper` (`overflow-x:auto`); only Year + the first school column are visible, second column cut off with no scroll affordance (`j3-compare-two-schools-mobile.png`).
|
||||||
|
- Criterion violated: mobile-usability standard (horizontal scrolling of primary content is a known antipattern) + Nielsen #6 (Recognition rather than recall) — a parent must remember school A's numbers while scrolling to school B; the tool's core "side-by-side" promise is defeated on the primary viewport.
|
||||||
|
- Argument: the whole reason a parent opens compare is to see schools next to each other; on mobile they can't, and with no scroll cue may believe the second school's data is missing and leave.
|
||||||
|
- Severity guess: P2 (the stacked metric cards + chart do show both, softening it)
|
||||||
|
|
||||||
|
- **F2. Comparison table scroll region is not keyboard-accessible**
|
||||||
|
- Evidence: axe `scrollable-region-focusable` (serious, 1 node) on `.ComparisonView-module__…tableWrapper`; the overflowing wrapper has no `tabindex`.
|
||||||
|
- Criterion violated: WCAG 2.2 **2.1.1 Keyboard (A)**.
|
||||||
|
- Argument: a keyboard-only parent cannot scroll the detailed table to reveal columns beyond the first school — data is literally unreachable without a mouse/touch.
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F3. Trend chart exposes no text alternative to assistive tech**
|
||||||
|
- Evidence: axe `role-img-alt` (serious) on `canvas`; `aria-label` = null on both mobile and desktop.
|
||||||
|
- Criterion violated: WCAG 2.2 **1.1.1 Non-text Content (A)**.
|
||||||
|
- Argument: a screen-reader parent gets nothing from "Performance Over Time"; the redundant table softens this but the chart's at-a-glance trend story is lost. Recurs from Journey 2 F5 — systemic to the chart component.
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F4. Share link is silently overridden by the visitor's own localStorage**
|
||||||
|
- Evidence: navigating to `/compare?urns=142161,113105` while `selectedSchools` held a different set resolved the page (and URL) back to the stored `urns=142161,136916`; only after clearing localStorage did the shared URL load its intended schools.
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status) / consistency; task-flow obstruction of an **advertised primary feature** ("Share" button).
|
||||||
|
- Argument: a parent shares their shortlist with a partner; if the recipient has ever used compare, they silently see *their own* schools with no error or notice — the shared comparison is unreproducible and the collaboration breaks. This directly hits the 20% who enter on `/compare` via links.
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F5. Add-school modal lacks dialog semantics and loses focus on add/close**
|
||||||
|
- Evidence: modal has no `role="dialog"` and no `aria-modal`; after a keyboard "+ Compare" add, `document.activeElement` = `BODY`; after Escape-close (Escape does dismiss it), focus is again on `BODY`, not the "+ Add School" trigger.
|
||||||
|
- Criterion violated: WCAG 2.2 **4.1.2 Name, Role, Value (A)** + **2.4.3 Focus Order (A)**.
|
||||||
|
- Argument: screen-reader users aren't told a dialog opened; keyboard users lose their place after every add (focus jumps to page top) and after closing, so building a multi-school comparison means re-tabbing from the top repeatedly — friction on the add flow every comparison depends on.
|
||||||
|
- Severity guess: P2 (P1-candidate for keyboard/AT users building 3+ school comparisons)
|
||||||
|
|
||||||
|
- **F6. Colour-contrast failures on the primary compare controls**
|
||||||
|
- Evidence: axe `color-contrast` (serious, 5 nodes populated) — sample targets `.btn-primary` (the "+ Add School" / "+ Compare" buttons), `.…phaseTabActive` (active Primary/Secondary tab), `.…tabActive .tabLabel` (active bottom-nav label).
|
||||||
|
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
|
||||||
|
- Argument: the exact orange buttons and active tabs a parent must use to build and read a comparison have insufficient text contrast — hard to read on a phone in daylight (the mobile-primary audience). Recurs site-wide (Journey 1/2).
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F7. Mixed-phase selection reports "Comparing 2 schools" while showing one**
|
||||||
|
- Evidence: with a primary + a secondary selected, header reads "Comparing 2 schools" but only the primary card/chart/table render; the secondary is hidden behind a "Secondary (1)" tab with no explanatory copy.
|
||||||
|
- Criterion violated: Nielsen #1 (Visibility of system status) & #2 (Match between system and real world).
|
||||||
|
- Argument: a parent who added two schools sees one and may think the second was dropped; nothing explains the phase split. (Separating KS2 vs GCSE metrics is itself correct — see Works well — only the count/label is misleading.)
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
- **F8. Remove is instant with no undo or confirmation**
|
||||||
|
- Evidence: tapping the × removed a school immediately — no confirm, no toast, no undo; URL updated instantly.
|
||||||
|
- Criterion violated: Nielsen #3 (User control & freedom — support undo).
|
||||||
|
- Argument: an accidental tap on the small (28px) × on mobile silently loses a school the parent may have spent a name-search to add; recovery means re-searching. A one-tap "undo" would prevent the loss.
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
- **Empty state is instructive *and* actionable**: clear "No schools selected" message plus a "+ Add Schools to Compare" button that opens an in-page search modal — the 20% who enter cold on `/compare` can start immediately (answers Journey 2's "no on-ramp" concern *at the compare page itself*).
|
||||||
|
- **Metric selector carries a plain-English caption** for the chosen metric (e.g. "% meeting expected standard in reading, writing and maths") — jargon defined in place, exactly what school-detail pages omitted.
|
||||||
|
- **Search accepts name and postcode** (TA1 5AU resolved to the correct school) — the advertised postcode feature works.
|
||||||
|
- **Efficient multi-add**: the modal stays open and clears the field after each add, so adding several schools needs no reopen.
|
||||||
|
- **Robust persistence + deep-linking**: selection survives real navigation away and back (localStorage), and comparisons are fully URL-encoded/shareable.
|
||||||
|
- **Chart accessibility basics done right for sighted users**: legend labels each series by colour **and** full school name (WCAG 1.4.1 satisfied); desktop hover tooltip gives precise per-year values; the table repeats those values as text.
|
||||||
|
- **Phase separation** (Primary/Secondary tabs) sensibly prevents nonsensical KS2-vs-GCSE metric comparisons.
|
||||||
|
- **Desktop keyboard + reflow**: visible 2px orange focus outlines on controls; modal opens with focus moved into the search field; Escape dismisses it; all columns visible without overflow; content reflows at 200% zoom with no page-level horizontal scroll (WCAG 1.4.10 pass).
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
- `/compare` (empty) @ 390×844: **1 violation** — `color-contrast` (serious, 2 nodes: active nav tab label, `.btn-primary`).
|
||||||
|
- `/compare?urns=142161,113105` (populated, 2 schools) @ 390×844: **3 violations** — `color-contrast` (serious, 5 nodes: `.btn-primary`, active phase tab, active nav tab), `role-img-alt` (serious, 1 node: `canvas`), `scrollable-region-focusable` (serious, 1 node: `.tableWrapper`).
|
||||||
|
- Desktop 1440×900 populated: `canvas` `aria-label` still null (role-img-alt persists); `color-contrast` on `.btn-primary`/active tabs persists (viewport-independent).
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
- Touch targets ≥24px on interactive elements (WCAG 2.5.8): **pass** — "+ Add School" 139×38, Share 110×40, metric select 324×41, phase tabs ~130×40. Smallest is the **× remove at 28×28** — above the 24px minimum but the tightest target and below the 44px comfortable norm.
|
||||||
|
- Keyboard: tab order / focus visibility (desktop): **pass with a gap** — logical order and visible 2px orange outlines on controls; modal opens with focus moved into the search box; **fail on focus restoration** — focus drops to `body` after an add and after Escape-close rather than returning to a sensible place (F5).
|
||||||
|
- Zoom 200% text reflow (desktop, 720×450 proxy): **pass** — no page-level horizontal scroll; only the detailed table retains its contained scroll.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
- `j3-compare-empty-mobile.png`: true empty state (localStorage cleared) — "No schools selected" + "+ Add Schools to Compare".
|
||||||
|
- `j3-compare-addmodal-mobile.png`: add-school modal with search field.
|
||||||
|
- `j3-compare-two-schools-mobile.png`: mobile 2-school comparison (cards stack; detailed table shows only first school column).
|
||||||
|
- `j3-compare-chart-mobile.png`: element capture proving the chart renders with a colour+name legend.
|
||||||
|
- `j3-compare-two-schools-desktop.png`: desktop 2-school comparison (both columns visible).
|
||||||
|
- `j3-compare-chart-tooltip-desktop.png`: mouse-hover tooltip with per-school values (3-school legend, colour + label).
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Journey 4: Rankings → shortlist — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** `/rankings`, `/rankings?local_authority=Solihull`, `/rankings?local_authority=Solihull&metric=rwm_expected_pct`, `/school/{urn}` (drill-in + back)
|
||||||
|
**Viewports:** 390×844, 1440×900
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
Parent scenario: "show me the good schools around here" — filter to a local authority, scan, jump to a school (and ideally shortlist a few to compare).
|
||||||
|
|
||||||
|
**Mobile (390×844):**
|
||||||
|
1. Loaded `/rankings`. The filter controls (Metric / Area / Year) are visible directly above the list — no accordion or hidden panel, so filtering is immediately discoverable (good). Default subtitle: "Top-performing schools by reading, writing & maths combined higher % — showing top 100".
|
||||||
|
2. Filtered to Solihull: one interaction on the **Area** native select → picked "Solihull". List updated to 54 schools; URL became `?local_authority=Solihull`. Seconds-to-goal: fast, ~1 select interaction.
|
||||||
|
3. Changed **Metric** to "Reading, Writing & Maths Combined %". Subtitle updated dynamically to "% meeting expected standard in reading, writing and maths"; URL gained `&metric=rwm_expected_pct`. Both filter and metric are encoded in the URL.
|
||||||
|
4. Drilled into a school by tapping the school-name link; navigated back (browser back). **Filter (Solihull), metric, and vertical scroll position (2500px) were all restored.** This is the strongest result of the journey.
|
||||||
|
5. Observed that on mobile the **Type** and **Action** columns are `display:none`, and the metric % column sits past the initial fold requiring horizontal scroll of a nested table wrapper.
|
||||||
|
|
||||||
|
**Desktop (1440×900):** Full 6-column table (RANK, SCHOOL, AREA, TYPE, metric %, ACTION with View / +Compare). Table is a real `<table>` with `<thead>`/`<th>`. Ran axe, keyboard/focus checks, and a 200%-zoom reflow proxy (720px width).
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. The ranking numbers themselves are low-contrast teal (fails AA)**
|
||||||
|
- Evidence: `j4-rankings-desktop.png`; axe `color-contrast` (serious) reported **100 nodes** on desktop, sampleTargets include `.valueCell strong` (the teal "63.0%", "60.0%"… values in every row) and the teal "+ Compare" links. Value colour ≈ teal `rgb(45,125,125)` on cream.
|
||||||
|
- Criterion violated: WCAG 2.2 AA 1.4.3 Contrast (Minimum).
|
||||||
|
- Argument: The percentage is the entire reason a parent is on this page — it is the score they are comparing schools by. Rendering the primary data in a colour that fails contrast makes the key number hard to read for low-vision parents (and outdoors on a phone). (Low-contrast text also seen on earlier journeys, but here it degrades the core content, not chrome.)
|
||||||
|
- Severity guess: P1
|
||||||
|
|
||||||
|
- **F2. Cannot add a school to the compare shortlist from the ranked list on mobile**
|
||||||
|
- Evidence: computed style — the `td` in the "Action" column (containing "View" and "+ Compare") is `display:none` at 390px; visible only at desktop width. The bottom nav shows a "Compare (3)" feature, so shortlisting is a first-class task.
|
||||||
|
- Criterion violated: Task-flow obstruction; Nielsen "User control & freedom" / "Flexibility & efficiency". The journey is literally rankings → shortlist, and the shortlist action is removed on the primary (mobile) viewport.
|
||||||
|
- Argument: A parent on a phone scanning the good local schools cannot build a comparison set from the rankings — they must open each school page individually and find another route in, adding steps to the core task on the highest-traffic device class.
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F3. The ranking value is off-screen on mobile — the list shows names but not scores without horizontal scroll**
|
||||||
|
- Evidence: `j4-rankings-mobile.png`, `j4-rankings-filtered-mobile.png`. RANK + SCHOOL + AREA fill the 324px table wrapper; the metric % column (e.g. "86.0%") is present but requires horizontally scrolling the nested `.tableWrapper` (scrollWidth 551 > clientWidth 324) to reveal.
|
||||||
|
- Criterion violated: Mobile usability (primary content not visible in the initial viewport). (Data tables are a recognised 1.4.10 Reflow exception, so noted as usability friction rather than a hard SC failure.)
|
||||||
|
- Argument: A parent sees a ranked list of names but not the numbers that justify the ranking, unless they discover a sideways swipe inside the table. The comparison value — the point of the page — is hidden by default.
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F4. Horizontally-scrollable table wrapper is keyboard-inaccessible and unlabelled**
|
||||||
|
- Evidence: `.RankingsView…tableWrapper` has `overflow-x:auto` with `scrollWidth > clientWidth`, but no `tabindex="0"`, no `role`, no `aria-label`.
|
||||||
|
- Criterion violated: WCAG 2.2 A 2.1.1 Keyboard (scrollable region not operable by keyboard); also 1.3.1 (unnamed region for screen readers).
|
||||||
|
- Argument: Keyboard-only and switch users cannot scroll the table sideways to reach the off-screen metric/Action columns; screen-reader users get no region name. (Keyboard-inaccessible scrollable regions also seen on earlier journeys.)
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F5. Default ranking uses unexplained "higher standard" jargon**
|
||||||
|
- Evidence: default metric is "Reading, Writing & Maths Combined **Higher** %"; top school scores 63.0%. Subtitle says "% achieving higher standard in reading, writing & maths combined" — but "higher standard" (greater depth) itself is not explained, and it is not the more familiar "expected standard".
|
||||||
|
- Criterion violated: Nielsen "Match between system and the real world" / "Help & documentation".
|
||||||
|
- Argument: A parent who doesn't know that "higher standard" means the top ~1 in 6 pupils may misread 63% as mediocre, when 63% at the higher standard is exceptional. The default frames every school's headline number in terms the audience is least likely to understand. (The chosen **year** — 2024/25 — is shown in the Year select but not repeated in the results subtitle.)
|
||||||
|
- Severity guess: P2
|
||||||
|
|
||||||
|
- **F6. 33-option metric dropdown is a flat list of dataset jargon**
|
||||||
|
- Evidence: metric select has 33 options including "GPS Expected %", "Maths Progress", "Reading Average Score", "Disadvantaged Gap", "% EAL Pupils", "% SEN Support", "% Pupil Stability", "GPS Test Absence %" — one ungrouped list, no plain-language expansion of abbreviations.
|
||||||
|
- Criterion violated: Nielsen "Recognition rather than recall" / "Match with the real world".
|
||||||
|
- Argument: A parent looking for "the good schools" must wade past acronyms (GPS, EAL, SEN) and technical measures (scaled "Average Score", "Progress") with no grouping or help text to find the metric they actually understand.
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
- **F7. No column-sort affordance; `<th>`s lack `scope`**
|
||||||
|
- Evidence: `<thead>` cells have `cursor:auto`, no `aria-sort`, no button, and are not clickable; no `scope` attribute and no `<caption>`. Ordering is controllable only via the Metric dropdown.
|
||||||
|
- Criterion violated: Nielsen "Consistency & standards" (users expect a ranked data table to sort by clicking a column header); WCAG 1.3.1 best-practice (missing `scope` on header cells).
|
||||||
|
- Argument: Parents who click "READING, WRITING & MATHS…" expecting to re-sort get no response; the only re-order path (a separate dropdown) is less discoverable than the convention they expect.
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
- **F8. Filter selects remove the focus outline for a subtle border-colour change**
|
||||||
|
- Evidence: CSS `.filter-select:focus { border-color: var(--accent-teal); outline: none; }`. Links and buttons, by contrast, get a strong 2px orange focus ring (`rgb(224,114,86)`).
|
||||||
|
- Criterion violated: WCAG 2.2 AA 2.4.11 Focus Appearance (indicator area/contrast); inconsistent focus treatment.
|
||||||
|
- Argument: A keyboard user tabbing through Metric/Area/Year gets only a faint 1.5px border hue shift — much weaker than the focus ring everywhere else — making it easy to lose track of focus while operating the filters.
|
||||||
|
- Severity guess: P3
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
|
||||||
|
- **Back-navigation preserves full state.** After drilling into a school and pressing back, the LA filter, the chosen metric, AND the vertical scroll position were all restored (verified scroll 2500px + `area=Solihull` + `metric=rwm_expected_pct`). State lives in the URL, so results are also shareable/bookmarkable. This is the classic mobile task-killer check and the page passes cleanly.
|
||||||
|
- **The selected metric is explained in plain language and updates live.** Switching the dropdown changed the subtitle from "% achieving higher standard…" to "% meeting expected standard in reading, writing and maths" — good real-time sensemaking.
|
||||||
|
- **Filter selects are properly labelled.** Each select has an associated visible `<label>` (Metric / Area / Year). The unlabelled-select (WCAG 4.1.2 select-name) issue seen on the home results state does NOT recur here.
|
||||||
|
- **Semantic table.** Real `<table>` with `<thead>` and `<th>` header cells — screen readers announce it as a table with columns, not a pile of divs.
|
||||||
|
- **Skip link + logical tab order.** A "Skip to main content" link is first in the tab order, followed by nav → phase tabs → the three filter selects → table rows, in reading order.
|
||||||
|
- **Reflow at 200% / narrow width.** At 720px (200% zoom proxy) the filters stack vertically and there is no page-level horizontal scroll.
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
|
||||||
|
- `/rankings` @ 390×844 (default): **1** violation — `color-contrast` (serious, 2 nodes: active nav tab + active "Primary (KS2)" phase tab, white on orange).
|
||||||
|
- `/rankings?local_authority=Solihull` @ 390×844 (filtered): **1** violation — `color-contrast` (serious, 2 nodes: same active tabs).
|
||||||
|
- `/rankings` @ 1440×900 (default): **1** violation — `color-contrast` (serious, **100 nodes**: active tabs + `.valueCell strong` teal metric numbers across every row, plus teal "+ Compare" links). Node count jumps on desktop because the full 100-row table with visible metric values is rendered.
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
|
||||||
|
- Touch targets ≥24px on interactive elements: **Pass (for visible targets).** School-name link 123×86px, Metric select 324×41px, phase tab 148×40px — all comfortably above 24px. Note: View / +Compare buttons are hidden on mobile (see F2), so no mobile shortlist target exists.
|
||||||
|
- Keyboard: tab order, focus visibility (desktop): **Mostly pass, one weakness.** Tab order logical (skip-link → nav → tabs → filters → rows); links and buttons show a strong 2px orange focus ring; filter selects suppress the outline for a faint teal border change (see F8).
|
||||||
|
- Zoom 200% text reflow (desktop): **Pass.** Filters reflow to a single column; no horizontal page scroll at 720px effective width.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
- `j4-rankings-mobile.png`: default `/rankings` at 390×844 — filters visible above list; table shows RANK/SCHOOL/AREA with metric % cut off to the right.
|
||||||
|
- `j4-rankings-filtered-mobile.png`: `/rankings?local_authority=Solihull` at 390×844 — 54 Solihull schools.
|
||||||
|
- `j4-rankings-desktop.png`: `/rankings` at 1440×900 — full 6-column table; teal metric values and teal "+ Compare" links visible (low-contrast, F1).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Journey 5: Admissions content — audit notes
|
||||||
|
|
||||||
|
**Pages visited:** `/` (reverse-discoverability check), `/admissions`
|
||||||
|
**Viewports:** 390×844 (mobile only — light pass per brief)
|
||||||
|
|
||||||
|
## Task attempt log
|
||||||
|
|
||||||
|
Goal of this pass: light single-viewport review of `/admissions`, plus the open question — is 5% of page views low because parents don't need admissions content, or because they can't find it?
|
||||||
|
|
||||||
|
1. Loaded `/` at 390×844 and inspected the full accessibility snapshot for every path into admissions content. Found three independent paths, all one tap from the homepage: (a) a persistent bottom tab bar with a dedicated "Admissions" tab visible on every page without scrolling, (b) a "Key admissions deadlines" widget on the homepage itself (above the fold on most phones, right below the search box and "Schools near me" button) with a "Full admissions guide →" link, and (c) a footer "Admissions guide" link. No hunting required — this is unusually well-exposed for content that gets only 5% of views.
|
||||||
|
2. Navigated to `/admissions`. Page loaded a full guide: hero with a live "days until next milestone" countdown strip, a sticky in-page nav ("Primary / Secondary / Tips"), a "Primary school admissions" timeline (6 stages: research criteria → portal opens → deadline → offer day → accept/decline → appeals), an identically structured "Secondary school admissions" timeline, and a "Three things most parents get wrong" section.
|
||||||
|
3. Checked heading hierarchy via `document.querySelectorAll('h1,h2,h3,h4')`: H1 "School Admissions Guide" → H2 "Primary school admissions" → H2 "Secondary school admissions" → H2 "Three things most parents get wrong" → H3×3 tip headings → (footer landmark) H3 "SchoolCompare" → H4×2. Clean, no skipped levels.
|
||||||
|
4. Checked line length on `main p` elements via `getBoundingClientRect` + `textContent.length`: body copy renders in a ~240px column at ~14px font, roughly 30–32 characters per line. Narrow, but this is a function of the 390px viewport and card padding, not a defect — not flagged as a friction point.
|
||||||
|
5. Checked the "recently added" SchoolCompare tool cross-links: every one of the 12 timeline steps (6 primary + 6 secondary) ends with a contextual CTA linking to `/` ("Find schools & view their admissions history", "Look up your allocated school"), `/compare` ("Build and compare your shortlist", "Weigh your offer against your other choices"), or `/rankings` ("Compare performance to order your preferences", "Gather performance evidence for your case"). Copy is tailored to the specific stage (e.g. the deadline step links to rankings framed as "order your preferences", not a generic "see rankings"). These read as genuinely contextual, not bolted on.
|
||||||
|
6. Checked touch target sizes on cross-link CTAs via `getBoundingClientRect`: most are 42px tall, well above minimum, but two variants ("Build and compare your shortlist →" and "Look up your allocated school →") measure only 21px tall.
|
||||||
|
7. Ran the axe-core snippet (`axe-snippet.js`) verbatim via `browser_evaluate`. 1 violation type, `color-contrast` (serious), 13 nodes, including the active bottom-nav tab label, the "England · Primary & Secondary" eyebrow, and the deadline countdown chip text.
|
||||||
|
8. Visual cohesion check (screenshot `j5-admissions-mobile.png`, full page): same cream background, coral/teal accent palette, card styling, and typography as the homepage's "Key admissions deadlines" widget. No visible inconsistency with the rest of the site in this pass.
|
||||||
|
|
||||||
|
**Discoverability answer:** Not a discoverability problem on the evidence gathered here. Admissions is reachable in 1 tap from any page (bottom tab bar) and is additionally surfaced unprompted on the homepage itself via the deadlines widget and in the footer. If 5% of views is "low," the more likely explanations are demand-side (most visits are one-off school lookups, not admissions research) or a mismatch between when parents need this content (a narrow autumn/winter window) and when they're on the site — not a findability failure.
|
||||||
|
|
||||||
|
## Friction points
|
||||||
|
|
||||||
|
- **F1. Color-contrast failures on nav label, eyebrow, and deadline chip text**
|
||||||
|
- Evidence: axe-core `color-contrast` rule, impact `serious`, 13 nodes. Sample targets: `.Navigation-module__Pj2Xoq__tabActive .tabLabel`, `.AdmissionsView-module__HSIWdq__eyebrow`, `.AdmissionsView-module__HSIWdq__chipDeadline .chipTrackDeadline`.
|
||||||
|
- Criterion violated: WCAG 2.2 SC 1.4.3 Contrast (Minimum), AA.
|
||||||
|
- Argument: the affected elements include the deadline countdown chips — the single most load-bearing piece of information on this page (parents are here specifically to check "how many days until the deadline"). Low-vision parents reading this on a phone in poor lighting are the exact audience this feature is for.
|
||||||
|
- Severity guess: P1 (WCAG failure, so not capped at P2; serious axe impact on primary task-critical content).
|
||||||
|
|
||||||
|
- **F2. Two cross-link CTA styles fall below the 24px touch-target minimum**
|
||||||
|
- Evidence: `getBoundingClientRect()` on all `main a` elements — "Build and compare your shortlist →" and "Look up your allocated school →" both measure 21px tall (vs. 42px for the other 4 CTA variants on the same page).
|
||||||
|
- Criterion violated: WCAG 2.2 SC 2.5.8 Target Size (Minimum), AA (24×24 CSS px, with only a narrow exception for links inline within a sentence — these render as standalone single-line CTAs, not text wrapped mid-paragraph, so the exception is doubtful).
|
||||||
|
- Argument: these are two of the twelve "recently added" cross-links meant to funnel admissions readers into the Search/Compare/Rankings tools — the exact conversion path this content exists to support. A tap target 3px under spec, inconsistent with sibling CTAs at 42px on the same page, adds avoidable mis-tap friction for a stressed parent thumbing through a countdown page.
|
||||||
|
- Severity guess: P2 (WCAG failure but a small, inconsistent-styling shortfall rather than a missing target).
|
||||||
|
|
||||||
|
## Works well — keep
|
||||||
|
|
||||||
|
- Admissions is discoverable in one tap from anywhere (bottom tab bar) and is proactively surfaced on the homepage (deadlines widget) and footer — this rules out "parents can't find it" as the likely explanation for low view share.
|
||||||
|
- The 12 contextual cross-links from admissions timeline steps into Search/Compare/Rankings are well-targeted to the specific stage of the journey (e.g., "Compare performance to order your preferences" at the deadline step, not a generic link) — a good example of tool cross-linking done with intent rather than bolted on.
|
||||||
|
- Heading hierarchy is clean (H1→H2→H3, no skipped levels), which matters for screen-reader users navigating a long timeline page by heading.
|
||||||
|
- Visual style is consistent with the rest of the site (palette, card treatment, typography match the homepage) — no cohesion break found in this pass.
|
||||||
|
|
||||||
|
## Axe results
|
||||||
|
|
||||||
|
- `/admissions` @ 390×844: 1 violation — `color-contrast` (serious, 13 nodes)
|
||||||
|
|
||||||
|
## Manual WCAG spot checks
|
||||||
|
|
||||||
|
- Touch targets ≥24px on interactive elements: fail — 2 of the 12 cross-link CTAs measure 21px tall (see F2); bottom-nav tabs (56px) and sticky in-page nav links (35px) pass.
|
||||||
|
- Keyboard: tab order, focus visibility (desktop only): not checked — mobile-only pass per brief.
|
||||||
|
- Zoom 200% text reflow (desktop only): not checked — mobile-only pass per brief.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
- `j5-home-mobile-top.png`: homepage above the fold at 390×844, showing the bottom tab bar with an "Admissions" tab and the "Key admissions deadlines" widget with "Full admissions guide →" link — evidence for the discoverability finding.
|
||||||
|
- `j5-admissions-mobile.png`: full-page screenshot of `/admissions` at 390×844, used for the cohesion and layout review.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# SchoolCompare UX/UI Audit — 2026-07-02
|
||||||
|
|
||||||
|
## Method summary
|
||||||
|
|
||||||
|
Journey-led walk-through of the **live site** (schoolcompare.co.uk) via Playwright browser tools, at two viewports: **390×844 mobile (primary — 56% of traffic)** and **1440×900 desktop** (720×450 used as the 200%-zoom reflow proxy). Five journeys in traffic order — home→find-school (63% of entries), cold landing on a school page (SEO long tail), building a comparison (27% of views), rankings→shortlist (12%), admissions (5%, light pass) — followed by a cross-cutting cohesion pass comparing computed typography, colour, and component styles across all six page types, verified against `nextjs-app` source to distinguish "token exists but bypassed" from "no token exists".
|
||||||
|
|
||||||
|
Every page state visited was scanned with **axe-core 4.10.2** (WCAG 2.2 A/AA rule set) plus manual checks: touch targets ≥24px (2.5.8), keyboard order and focus visibility, and 200% reflow. A finding is included only if it cites a Nielsen heuristic, a WCAG 2.2 AA failure, a mobile-usability standard, or an observed task-flow obstruction — no taste-only findings. Priorities are traffic-weighted using the 30-day analytics baseline: entries `/` 63% / `/compare` 20% / `/rankings` 6%; exits `/` 46% / `/compare` 32% / `/rankings` 13%; 56% mobile. Uplift bands are honest small/moderate/large indications — there is no funnel instrumentation to support percentages.
|
||||||
|
|
||||||
|
Full evidence trail: `docs/superpowers/specs/2026-07-02-ux-audit-notes/` (six notes files; screenshots referenced by filename live in the session scratchpad, not committed).
|
||||||
|
|
||||||
|
## What works today — keep
|
||||||
|
|
||||||
|
These are strengths the fixes below must not regress:
|
||||||
|
|
||||||
|
- **Name search is genuinely excellent.** "Welland Primary" → the correct school as sole top result, in 3 taps with instant loads — comfortably under the 15s target. Result cards are information-rich (Ofsted grade+year, RWM % with trend arrow, "+12 pts vs national", pupil count, LA) and adapt correctly to phase (Attainment 8 for secondary, RWM for primary).
|
||||||
|
- **Full-postcode proximity search works, and works well** *(verified on 2026-07-02 recheck)* — typing a full postcode (e.g. `B91 3DL`) into the home search box yields "13 schools within 1.0 miles of B91 3DL", distance on every card, nearest-first ordering, a 0.5–5 mile radius selector, and a List/Map toggle; the "Schools near me" geolocation button offers the same without typing. Only partial input degrades (see P2.0).
|
||||||
|
- **Search is the unmistakable primary action** — above the fold on both viewports, 70px tap target, plain-English placeholder, content order puts the task first.
|
||||||
|
- **The primary (KS2) school page explains its data in plain English** — "End-of-primary-school tests taken by Year 6 pupils", the in-place "Why is combined lower?" explainer, legends on subject charts. This is the model the secondary template should copy.
|
||||||
|
- **Everything is anchored to the national average** ("+14 pts", "National avg 39.1") — a non-specialist can judge good/bad without leaving the page.
|
||||||
|
- **Graceful, honest missing-data handling** — COVID-cancellation notes, "contact the admissions authority" for absent cut-offs; no broken or empty UI anywhere data is missing.
|
||||||
|
- **Ofsted sections are clear, current, and sourced** — grade + inspection date, the post-Sept-2024 grading-change note, link to the real Ofsted report.
|
||||||
|
- **Compare's empty state is instructive and actionable** — "No schools selected" + "+ Add Schools to Compare" opening an in-page search modal; the 20% who enter cold can start immediately. The modal supports efficient multi-add (stays open, clears field) and accepts postcodes.
|
||||||
|
- **State persistence is strong across the site** — compare selections survive navigation (localStorage) and are URL-encoded/deep-linkable; rankings restores filter, metric, *and* scroll position on back-navigation (the classic mobile task-killer check, passed cleanly).
|
||||||
|
- **Compare's metric selector carries a plain-English caption** — jargon defined at point of use; the chart legend labels series by colour *and* name (WCAG 1.4.1 satisfied).
|
||||||
|
- **Accessibility fundamentals are partly in place** — working skip link, logical tab order everywhere, strong 2px orange focus outlines on links/buttons, real `<table>` semantics on rankings, properly labelled rankings filters, clean heading hierarchy on admissions, no horizontal scroll at 200% reflow on any page tested.
|
||||||
|
- **Admissions content is well-integrated** — reachable in one tap from anywhere, surfaced proactively on the homepage, with 12 stage-tailored cross-links into Search/Compare/Rankings that read as genuinely contextual.
|
||||||
|
- **Cohesion anchors:** header/footer pixel-identical on all six pages; coral=primary / teal=secondary held without role collisions; rankings and compare share one phase-tab treatment; the deadline chip is byte-for-byte reused between home and admissions.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P0 — Urgent
|
||||||
|
|
||||||
|
- **P0.1 — Colour-contrast failures on every main route, including primary data and primary CTAs** *(merges J1-F4, J2-F4, J3-F6, J4-F1, J5-F1)*
|
||||||
|
- Evidence: axe `color-contrast` (serious) on **all five routes**: home 6 nodes (active nav tab, `.btn`, how-it-works text) + 3 on results (`.btn-primary`, Ofsted badge); school template **13 nodes** (Back button, "View on map", active metric tab); compare 5 nodes (`.btn-primary`, active phase/nav tabs); rankings **100 nodes on desktop** — the teal metric values (`.valueCell strong`) in every row plus "+ Compare" links; admissions 13 nodes including the **deadline countdown chips**. Screenshots `j4-rankings-desktop.png`, `j5-admissions-mobile.png`.
|
||||||
|
- Criterion: WCAG 2.2 **1.4.3 Contrast (Minimum), AA** — failing on every main page.
|
||||||
|
- Argument: this is not chrome-only. On rankings the failing text is the ranking score itself — the entire reason a parent is on the page; on admissions it is the days-until-deadline chip — the page's most load-bearing fact; on home/compare it is the primary CTA styling. The mobile-primary audience reads this outdoors on phones, where low contrast bites everyone, not just low-vision users.
|
||||||
|
- Recommendation: adjust the failing token values once (`--accent-coral` on light text pairings, the teal-on-cream value colour, tint-background label colours) and re-run axe on all five routes. This is a token-level fix, not per-page work.
|
||||||
|
- Uplift: **accessibility compliance — large improvement** (clears the single biggest violation class, present on 100% of audited pages); secondary small positive effect on all engagement metrics via legibility for the 56% mobile share.
|
||||||
|
|
||||||
|
- **P0.2 — School name (H1) is obscured by the map and the "+ Add to Compare" button on mobile — systemic to the school template** *(J2-F1)*
|
||||||
|
- Evidence: measured bounding boxes on both audited schools — Castle: map bottom 280px / H1 top 272px / compare-button top 286px / H1 bottom 309px; Welland: same pattern — leaving a ~6px legible sliver of the name. Screenshots `j2-school-mobile-fold.png`, `j2-welland-mobile-full.png`. Desktop is unaffected.
|
||||||
|
- Criterion: Nielsen #1 (visibility of system status) and #8; task-flow obstruction of cold-landing orientation; defeats the intent of WCAG 1.4.10 (content obscured at the primary viewport).
|
||||||
|
- Argument: school pages are the SEO landing template. A parent arriving from Google asks one question first — "is this the school I searched for?" — and the element that answers it is illegible on the majority viewport. This is the exact first-screen failure that produces an immediate back-to-Google bounce, on the template whose entire job is converting search traffic.
|
||||||
|
- Recommendation: restack the mobile hero so map, H1, and compare button occupy non-overlapping space (e.g. compare button below the identity block or docked, map height capped above the H1). One template, both school-detail components (see P2.6).
|
||||||
|
- Uplift: **school-page bounce-back-to-Google — decrease, moderate** (fixes the first-glance orientation on every SEO landing; the page content behind it is already strong, so retention should follow).
|
||||||
|
|
||||||
|
### P1 — High
|
||||||
|
|
||||||
|
- **P1.1 — Filter and Sort selects on home results have no accessible name** *(J1-F3)*
|
||||||
|
- Evidence: axe `select-name` (impact **critical**, 2 nodes) on `/?search=…` — the phase filter and sort selects. (Borderline P0 by the "clear WCAG failure on a main page" rule; held at P1 because it affects one page state and two controls.)
|
||||||
|
- Criterion: WCAG 2.2 **4.1.2 Name, Role, Value, Level A**.
|
||||||
|
- Argument: these are the controls a parent uses to narrow a mixed result set to "Primary" — a screen-reader user hears two anonymous comboboxes on the main journey's results view. Rankings labels its identical selects correctly, so this is an omission, not a pattern.
|
||||||
|
- Recommendation: associate visible `<label>`s (or `aria-label`) exactly as `/rankings` already does.
|
||||||
|
- Uplift: **accessibility compliance — moderate** (removes the only *critical*-impact violation found); minutes of effort.
|
||||||
|
|
||||||
|
- **P1.2 — School pages offer no "nearby / similar schools" path — the compare value proposition has no on-ramp** *(J2-F2, corroborated by J3 tap-count)*
|
||||||
|
- Evidence: full-page snapshots of both school pages show no nearby/similar module (sections end Wellbeing → footer). Building a real 2-school comparison from a school page requires Compare → "+ Add School" → **manual name search** — 6 interactions total, and the parent must already know the competitor's name (`j3-compare-two-schools-mobile.png`, `j2-compare-oneschool-mobile.png`).
|
||||||
|
- Criterion: Nielsen #7 (flexibility & efficiency); task-flow obstruction — the site's differentiator is unreachable from its SEO entry template without prior knowledge.
|
||||||
|
- Argument: a parent cold-landing on one school has maximum comparison intent and zero alternatives on screen. The site sends them back to Google to discover the other local schools — Google then keeps them.
|
||||||
|
- Recommendation: add a "Schools nearby" module to the detail template (nearest same-phase schools by lat/long — data already exists) with per-row "+ Compare"; the same list can power an in-modal "nearby" tab on `/compare`.
|
||||||
|
- Uplift: **school-page bounce-back-to-Google — decrease, moderate**, and **compare 32% exit — decrease, small-to-moderate** (creates the missing bridge between the two biggest content surfaces).
|
||||||
|
|
||||||
|
- **P1.3 — Shared compare links are silently overridden by the recipient's own localStorage** *(J3-F4)*
|
||||||
|
- Evidence: navigating to `/compare?urns=142161,113105` with a different stored selection resolved the page and URL back to the stored set; the shared schools never appeared. Reproduced; only clearing localStorage restored the link's intent.
|
||||||
|
- Criterion: Nielsen #1 (visibility of system status); task-flow obstruction of an advertised primary feature (the Share button).
|
||||||
|
- Argument: 20% of sessions *enter* on `/compare`, many via shared links. A parent shares a shortlist with a partner; if the partner has ever used compare, they silently see their own old schools with no notice — the collaboration breaks and neither party knows why.
|
||||||
|
- Recommendation: explicit URL params must win over localStorage (persist *after* honouring the URL); optionally prompt "Replace your saved comparison with the shared one?".
|
||||||
|
- Uplift: **compare 32% exit rate — decrease, small-to-moderate** (repairs a confusing dead end at a high-intent entry point; band limited by the shared-link share of entries being unmeasured).
|
||||||
|
|
||||||
|
- **P1.4 — Trend/results charts expose no text alternative to assistive tech** *(merges J2-F5, J3-F3)*
|
||||||
|
- Evidence: axe `role-img-alt` (serious) on the `<canvas>` on both school pages and populated `/compare`; `aria-label` is null at both viewports.
|
||||||
|
- Criterion: WCAG 2.2 **1.1.1 Non-text Content, Level A** — on the two main content templates.
|
||||||
|
- Argument: the site's key visualisation — performance over time — announces nothing to a screen-reader parent. Compare's data table partially mitigates; the school-page trend chart has only a collapsed raw-data expander.
|
||||||
|
- Recommendation: generate a descriptive `aria-label` per chart ("Attainment 8, 2019–2025: school 53.4 vs national 39.1, school above national every year") — the data is already in the component; one shared chart wrapper fixes all instances.
|
||||||
|
- Uplift: **accessibility compliance — moderate** (clears a Level-A class on the highest-traffic templates).
|
||||||
|
|
||||||
|
- **P1.5 — Horizontally-scrollable regions are keyboard-inaccessible site-wide** *(merges J1-F5(a), J3-F2, J4-F4)*
|
||||||
|
- Evidence: axe `scrollable-region-focusable` (serious) on the home countdown rail, the compare detailed-table wrapper, and (manually confirmed, same pattern) the rankings table wrapper — `overflow-x: auto` with no `tabindex="0"`, no role/label.
|
||||||
|
- Criterion: WCAG 2.2 **2.1.1 Keyboard, Level A**.
|
||||||
|
- Argument: keyboard-only users literally cannot reach the off-screen columns — which on compare and rankings contain the actual comparison data (see P2.1). Three instances of one missing pattern.
|
||||||
|
- Recommendation: `tabindex="0"` + `role="region"` + `aria-label` on every overflow wrapper; one shared wrapper component prevents recurrence.
|
||||||
|
- Uplift: **accessibility compliance — moderate**; unlocks the data for keyboard users wherever P2.1's layout still requires scrolling.
|
||||||
|
|
||||||
|
- **P1.6 — Search offers no autocomplete/typeahead** *(J1-F1)*
|
||||||
|
- Evidence: typing "Welland Primary" (both viewports) produced no suggestions at any keystroke — raw input until submit. The compare modal's live-results search proves the capability exists in the codebase.
|
||||||
|
- Criterion: Nielsen #6 (recognition rather than recall); NN-g site-search guidance; error prevention.
|
||||||
|
- Argument: parents rarely know a school's exact registered name ("Welland" vs "Welland Church of England…"). With no suggestions, a misspelling means a zero-result dead end on the 63%-of-entries path, and every search costs full typing plus a blind submit.
|
||||||
|
- Recommendation: reuse the compare modal's live-search behaviour on the home search box (debounced suggestions: school name + LA, and a "search this postcode" row for postcode-shaped input).
|
||||||
|
- Uplift: **home 46% exit rate — decrease, moderate** and **share of sessions reaching a school page — increase, moderate** (assists the majority entry path at its first interaction).
|
||||||
|
|
||||||
|
- **P1.7 — The mobile hero omits the value proposition entirely** *(J1-F6)*
|
||||||
|
- Evidence: desktop shows the "UPDATED WITH 2026/2027 ADMISSIONS RESULTS" trust badge and the "24,000+ schools… side by side, in one place" subheading; mobile renders only the poetic H1 ("Every school in England, *compared.*") and a bare search box (`j1-home-desktop-fold.png` vs `j1-home-mobile-fold.png`).
|
||||||
|
- Criterion: mobile content parity; Nielsen #1 — first-visit orientation ("what is this, why trust it") absent on the primary viewport.
|
||||||
|
- Argument: 63% of entries land here and 56% of traffic is mobile; a first-time visitor gets no statement of coverage, data source, or freshness above the fold. Weak value proposition at first glance is a classic bounce driver and plausibly a material slice of the 46% exit rate.
|
||||||
|
- Recommendation: restore a compact version of the badge + one-line value prop under the mobile H1 (one text block; the fold has room above the deadline rail).
|
||||||
|
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (copy-only change aimed squarely at first-impression exits).
|
||||||
|
|
||||||
|
### P2 — Medium
|
||||||
|
|
||||||
|
- **P2.0 — Partial or malformed postcode input silently falls back to text search with no distances** *(J1-F2, corrected on 2026-07-02 recheck — the original P0 claim that postcode search lacks proximity was wrong: a full postcode works, see "What works today")*
|
||||||
|
- Evidence: the search submit handler routes input through a strict full-postcode regex (`FilterBar.tsx` `isValidPostcode()`). `B91 3DL` → `/?postcode=…&radius=1`: "13 schools within 1.0 miles", distance on every card, nearest-first, radius selector, map toggle (`j1-fullpostcode-results-mobile.png`). But `B91 3` (an outcode+sector a parent plausibly types) → `/?search=B91+3`: text-matched, relevance-ordered, distance-less, mixed-phase results led by a secondary school (`j1-postcode-results-mobile.png`) — with no notice that a different, better mode exists.
|
||||||
|
- Criterion: Nielsen #1 (visibility of system status — silent mode switch) and #9 (help users recognise and recover); error tolerance for the "or postcode" promise in the placeholder.
|
||||||
|
- Argument: the two result pages look near-identical, so a parent who types "B91" or fat-fingers a character never learns that proximity search exists; they just get an apparently arbitrary list on the highest-traffic path.
|
||||||
|
- Recommendation: detect postcode-*shaped* input that fails full validation (outcode/sector patterns, near-miss typos) and either geocode it anyway (postcodes.io supports outcodes) or show an inline nudge — "Enter a full postcode like B91 3DL to see schools by distance."
|
||||||
|
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (recovers the postcode-search subset who type partial input; the full-postcode path already works, which caps the band).
|
||||||
|
|
||||||
|
- **P2.1 — Mobile data tables hide the decision-critical columns behind unaffordanced horizontal scroll** *(merges J3-F1, J4-F3)*
|
||||||
|
- Evidence: compare's "Detailed Comparison" table is 626px inside a 324px wrapper — only Year + the first school's column visible, no scroll cue (`j3-compare-two-schools-mobile.png`); rankings shows RANK/SCHOOL/AREA but the metric % (the ranking's justification) sits off-screen (scrollWidth 551 > 324) (`j4-rankings-mobile.png`).
|
||||||
|
- Criterion: mobile-usability standard (primary content hidden off-viewport); Nielsen #6 — the user must memorise school A's numbers while scrolling to school B.
|
||||||
|
- Argument: on compare the side-by-side promise is defeated on the majority viewport (a parent may believe the second school's data is missing); on rankings the list shows names without the scores that rank them. Softened by compare's stacked metric cards, which do show both schools — hence P2 not P1.
|
||||||
|
- Recommendation: on ≤390px, prioritise the metric column next to the school name (rankings) and use a per-metric stacked layout or sticky first column + visible scroll affordance (compare).
|
||||||
|
- Uplift: **compare 32% exit — decrease, small**; **rankings→school click-through — increase, small**.
|
||||||
|
|
||||||
|
- **P2.2 — Rankings on mobile removes the View/+Compare actions** *(J4-F2)*
|
||||||
|
- Evidence: the Action column `td` computes `display:none` at 390px; shortlisting from rankings is desktop-only.
|
||||||
|
- Criterion: task-flow obstruction (the journey is rankings → *shortlist*); Nielsen #7.
|
||||||
|
- Argument: a parent on a phone scanning the good local schools cannot build a comparison set from the list — each school needs a page visit and a different route in, on the majority device class.
|
||||||
|
- Recommendation: keep a compact "+" compare affordance per row on mobile (the bottom-tab badge already communicates basket state).
|
||||||
|
- Uplift: **rankings→school click-through / compare usage — increase, small-to-moderate** (restores the journey's intended endpoint on mobile).
|
||||||
|
|
||||||
|
- **P2.3 — Metric jargon unexplained at the point of first contact** *(merges J2-F3, J4-F5)*
|
||||||
|
- Evidence: secondary school pages show "Attainment 8 score 53.4", "EBacc average point score 4.72" with zero definitions/tooltips (the plain-English definition exists — but only on `/compare`); rankings *defaults* to "higher standard" (greater depth) without explaining it, so the top school's headline reads "63.0%" in the terms parents least understand (`j2-school-mobile-gcse.png`).
|
||||||
|
- Criterion: Nielsen #2 and #10.
|
||||||
|
- Argument: a non-specialist cannot judge whether 53.4 or 4.72 is good; a parent may misread 63% at higher standard as mediocre when it is exceptional. The site owns the explanations and withholds them at first contact. The primary KS2 page proves in-place explanation works.
|
||||||
|
- Recommendation: surface the existing `/compare` metric definitions on school pages (caption or expander beside each figure); default rankings to "expected standard" with "higher standard" as an explained option.
|
||||||
|
- Uplift: **school-page bounce-back-to-Google — decrease, small-to-moderate** (comprehension is the retention lever for the secondary template).
|
||||||
|
|
||||||
|
- **P2.4 — Add-school modal lacks dialog semantics and drops focus** *(J3-F5)*
|
||||||
|
- Evidence: no `role="dialog"`/`aria-modal`; after a keyboard add, focus lands on `document.body`; after Escape-close, focus is on `body`, not the trigger.
|
||||||
|
- Criterion: WCAG 2.2 **4.1.2 (A)** + **2.4.3 Focus Order (A)**.
|
||||||
|
- Argument: screen-reader users aren't told a dialog opened; keyboard users re-tab from the page top after every add — compounding friction on the flow every comparison depends on.
|
||||||
|
- Recommendation: dialog role + focus trap + focus restoration to the trigger on close; keep focus in the search field after an add (the multi-add UX is otherwise good).
|
||||||
|
- Uplift: **accessibility compliance — moderate**.
|
||||||
|
|
||||||
|
- **P2.5 — School-page sticky section nav clips its last items on mobile** *(J2-F7)*
|
||||||
|
- Evidence: at 390px, "History" and "Wellbeing" render off-screen; the 448px nav row is clipped, not scrollable (document scrollWidth stays 390) (`j2-school-mobile-gcse.png`).
|
||||||
|
- Criterion: mobile usability — interactive content unreachable in-viewport; Nielsen #7.
|
||||||
|
- Argument: 2 of 6 jump links are dead weight on the primary viewport; parents hunting SEN/Wellbeing data must scroll-hunt instead.
|
||||||
|
- Recommendation: make the nav row horizontally scrollable with an overflow affordance (fade/chevron), or wrap to two lines.
|
||||||
|
- Uplift: **school-page bounce-back-to-Google — decrease, small**.
|
||||||
|
|
||||||
|
- **P2.6 — Two parallel school-detail components have drifted; a duplicate `.btn` rule makes button geometry unpredictable** *(merges cohesion F1, F2)*
|
||||||
|
- Evidence: `SchoolDetailView` vs `SecondarySchoolDetailView` hardcode divergent values for the same controls (`.btnAdd` radius 8/pad 12×20 vs radius 6/pad 8×16; badge radii 4 vs 3; tab padding differs). Separately `globals.css` defines `.btn` **twice** (lines 151 and 1558) with `.btn-sm` declared between them — source order makes the second definition override `.btn-sm`, so "small" buttons render at 12×24px padding (live-verified on rankings).
|
||||||
|
- Criterion: Nielsen #4 (consistency & standards); maintenance hazard.
|
||||||
|
- Argument: parents moving between primary and secondary school pages (the compare flow mixes phases) meet the same controls at subtly different sizes/corners; and any future button edit has a 50/50 chance of landing in the dead rule.
|
||||||
|
- Recommendation: dedupe `.btn` to one definition (restoring `.btn-sm`); converge both school-detail modules on shared tokens (`--radius-md`), ideally one shared component.
|
||||||
|
- Uplift: **school-page bounce-back-to-Google — decrease, small** (visual consistency is a trust signal on the template that must convert cold traffic); also removes a recurring source of future drift.
|
||||||
|
|
||||||
|
- **P2.7 — Design tokens exist but are bypassed system-wide** *(merges cohesion F3, F4, F5)*
|
||||||
|
- Evidence: **15 distinct raw radius values** in module CSS against a 4-token scale (no pill token for the 21× `999px` uses); **four different H1 sizes** across five pages (36/44/48/52px), each a separate hardcoded `clamp()`; the "switch view/phase" control has **three different treatments** (rounded segmented on home, square tabs on rankings/compare, underline strip on admissions).
|
||||||
|
- Criterion: Nielsen #4.
|
||||||
|
- Argument: corner rounding, title scale, and segmented controls are the primary "one system" cues; the drift reads as many hands and erodes the data-source credibility the site trades on. (Rankings↔compare already share one tab treatment — the model to extend.)
|
||||||
|
- Recommendation: token-enforcement pass (map raw radii to tokens; add `--radius-pill`), create hero/section title tokens, consolidate the segmented control on the rankings/compare pattern.
|
||||||
|
- Uplift: **home 46% exit rate — decrease, small** (a visibly coherent system reads as a credible data source at first glance); primarily protects future velocity.
|
||||||
|
|
||||||
|
- **P2.8 — Two admissions cross-link CTA variants are below the 24px touch-target minimum** *(J5-F2)*
|
||||||
|
- Evidence: "Build and compare your shortlist →" and "Look up your allocated school →" measure **21px** tall vs 42px for the other four CTA variants on the same page (getBoundingClientRect).
|
||||||
|
- Criterion: WCAG 2.2 **2.5.8 Target Size (Minimum), AA** (rendered as standalone CTAs, so the inline-text exception is doubtful).
|
||||||
|
- Argument: these are the conversion path the admissions content exists to support; a 3px-under-spec target inconsistent with siblings adds mis-tap friction for a stressed parent. (Admissions-only, but a WCAG failure, so it stays P2 per the prioritisation rules.)
|
||||||
|
- Recommendation: apply the 42px CTA style to all twelve cross-links.
|
||||||
|
- Uplift: **accessibility compliance — small**.
|
||||||
|
|
||||||
|
### P3 — Nice-to-have
|
||||||
|
|
||||||
|
- **P3.1 — Mixed-phase selection reports "Comparing 2 schools" while rendering one** *(J3-F7)* — the second school hides behind a "Secondary (1)" tab with no explanation (Nielsen #1/#2). Add one line of copy ("Shown separately — KS2 and GCSE metrics differ"). Uplift: compare exit, small.
|
||||||
|
- **P3.2 — Compare remove (×) is instant with no undo** *(J3-F8)* — accidental tap on the 28px target silently loses a searched-for school (Nielsen #3). Add a brief undo toast. Uplift: compare exit, small.
|
||||||
|
- **P3.3 — "Add to Compare" gives no toast/live-region confirmation** *(J2-F8)* — feedback is only a button relabel in the (currently overlapped) hero zone plus a small tab badge; no `role=status` fires (Nielsen #1). Largely mitigated once P0.2 lands. Uplift: compare usage, small.
|
||||||
|
- **P3.4 — Inconsistent focus indicators on form controls** *(merges J1 search-input note, J4-F8)* — filter selects suppress the outline for a faint 1.5px border hue shift; the home search input uses a 0.12-alpha ring — both far weaker than the site's standard 2px orange outline (WCAG 2.4.11 borderline). Apply the standard outline. Uplift: accessibility compliance, small.
|
||||||
|
- **P3.5 — Rankings table headers aren't sortable and lack `scope`** *(J4-F7)* — users expect ranked tables to sort on header click (Nielsen #4); `<th>` cells lack `scope`/`aria-sort` (WCAG 1.3.1 best practice). Uplift: rankings→school CTR, small.
|
||||||
|
- **P3.6 — 33-option metric dropdown is a flat jargon list** *(J4-F6)* — GPS/EAL/SEN abbreviations, ungrouped (Nielsen #6). Group by theme (Results / Progress / Cohort) with plain-language labels. Uplift: rankings→school CTR, small.
|
||||||
|
- **P3.7 — Home deadline rail defaults its scroll to the least-urgent card** *(J1-F5(b))* — the nearest deadline (121 days) hides behind less urgent ones (Nielsen #1). Order/scroll to soonest-first. Uplift: home exit, small.
|
||||||
|
- **P3.8 — School trend chart has no legend for the national series; badge overlaps a heading** *(J2-F6)* — the grey dashed line is unexplained (the primary page's charts *do* carry legends — inconsistent); "Nat avg 39.1" overlaps the section heading. Uplift: school-page bounce, small.
|
||||||
|
- **P3.9 — Residual colour/type token drift** *(cohesion F6, F7, F8)* — pressed-coral exists as three hexes (`#e07256`/`#c45a3f`/`#d4654a`), an off-token gold `#b8920e` on the school-page cross-link badge, H3s render in body font while H2s are Playfair, and school-detail tables use an 11/13px type scale vs 12/15px on rankings/compare. Fold into the P2.7 token pass. Uplift: school-page bounce-back-to-Google, decrease, small.
|
||||||
|
|
||||||
|
## Accessibility summary
|
||||||
|
|
||||||
|
Axe-core 4.10.2, WCAG 2.2 A/AA rule set. Desktop serves the same DOM/CSS, so mobile results apply at both viewports unless noted.
|
||||||
|
|
||||||
|
| Page / state | Viewport | Violations | Detail |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` (initial) | 390×844 | 2 | `color-contrast` (serious, 6 nodes); `scrollable-region-focusable` (serious, 1) |
|
||||||
|
| `/?search=Welland+Primary` | 390×844 | 2 | `color-contrast` (serious, 3); **`select-name` (critical, 2)** |
|
||||||
|
| `/school/…castle` (secondary) | both | 2 | `color-contrast` (serious, 13); `role-img-alt` (serious, 1) |
|
||||||
|
| `/school/…welland` (primary) | 390×844 | 1 | `role-img-alt` (serious, 1) |
|
||||||
|
| `/compare` (empty) | 390×844 | 1 | `color-contrast` (serious, 2) |
|
||||||
|
| `/compare?urns=…` (2 schools) | 390×844 | 3 | `color-contrast` (serious, 5); `role-img-alt` (serious, 1); `scrollable-region-focusable` (serious, 1) |
|
||||||
|
| `/rankings` (default & filtered) | 390×844 | 1 | `color-contrast` (serious, 2) |
|
||||||
|
| `/rankings` | 1440×900 | 1 | `color-contrast` (serious, **100 nodes** — metric values in every row) |
|
||||||
|
| `/admissions` | 390×844 | 1 | `color-contrast` (serious, 13 — incl. deadline chips) |
|
||||||
|
|
||||||
|
**Manual checks:** Touch targets ≥24px — pass on all core paths (smallest: compare × at 28px, Back at 64×28) **except** two admissions CTAs at 21px (P2.8). Keyboard — order logical everywhere with a strong 2px outline on links/buttons; gaps: unlabelled home-results selects (P1.1), modal focus loss (P2.4), suppressed outlines on filter selects and the faint search-input ring (P3.4), keyboard-unreachable scroll regions (P1.5). 200% reflow — **pass on every page tested** (no horizontal scroll at 720px; tables retain contained scroll, a recognised 1.4.10 exception).
|
||||||
|
|
||||||
|
**Overall WCAG 2.2 AA posture:** not currently conformant. Four violation classes account for everything axe found — contrast (every route), missing chart text alternatives (Level A), keyboard-inaccessible scroll regions (Level A), unlabelled selects (Level A, critical) — plus manual findings on target size, focus appearance, and dialog semantics. All are pattern-level fixes; none require redesign. The foundations (skip links, tab order, semantics, reflow, labels on rankings) are solid, which is why the fix list is short relative to the violation node counts.
|
||||||
|
|
||||||
|
## Suggested implementation sequence
|
||||||
|
|
||||||
|
Batches ordered by uplift-per-effort; each is a plausible standalone follow-up project.
|
||||||
|
|
||||||
|
**Batch 1 — WCAG compliance sweep (small effort, large compliance uplift).**
|
||||||
|
P0.1 contrast token fixes; P1.1 label the two selects; P1.4 chart `aria-label`s via one shared wrapper; P1.5 `tabindex`/role on the three scroll wrappers; P2.4 dialog semantics + focus restoration; P2.8 42px CTAs on admissions; P3.4 standard focus outlines. Mostly CSS-variable and attribute changes; re-run the axe harness on all five routes as the acceptance check. Clears every axe violation class found.
|
||||||
|
|
||||||
|
**Batch 2 — School-template mobile fold (small effort, direct SEO-retention uplift).**
|
||||||
|
P0.2 un-overlap the H1/map/compare button; P2.5 scrollable section nav; P3.8 trend-chart legend + badge overlap; P3.3 add-confirmation live region. One template, all SEO landings.
|
||||||
|
|
||||||
|
**Batch 3 — Home search assistance (small-moderate effort, home-funnel lever).**
|
||||||
|
P1.6 autocomplete on the home search (reuse the compare modal's live search); P2.0 graceful handling of partial/malformed postcodes (geocode outcodes or nudge toward a full postcode — the full-postcode proximity search already works); P1.7 mobile value-prop line. Together these target the 46% home exit rate from three directions.
|
||||||
|
|
||||||
|
**Batch 4 — Discovery and sharing bridges (moderate effort, compare-funnel uplift).**
|
||||||
|
P1.2 "Schools nearby" module on school pages (feeds compare); P1.3 URL-over-localStorage precedence for shared links; P2.2 mobile +Compare on rankings rows; P3.1/P3.2 compare state-clarity nits.
|
||||||
|
|
||||||
|
**Batch 5 — Comprehension pass (small-moderate effort).**
|
||||||
|
P2.3 reuse `/compare` metric definitions on school pages and default rankings to "expected standard"; P2.1 mobile table layouts (sticky first column / stacked metrics); P3.5/P3.6 rankings sort affordance + grouped metric picker; P3.7 deadline-rail ordering.
|
||||||
|
|
||||||
|
**Batch 6 — Cohesion & token enforcement (housekeeping, protects velocity).**
|
||||||
|
P2.6 dedupe `.btn`, converge the two school-detail components; P2.7 radius/title/segmented-control tokenisation; P3.9 residual colour/type drift. Best done after Batches 1–2 so the new token values land once.
|
||||||
@@ -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,187 @@
|
|||||||
|
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('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The geolocation shortcut lives inside the hero search card, right under the
|
||||||
|
// search input — not in a separate strip further down the page.
|
||||||
|
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
const nearMe = page.getByRole('button', { name: /use my location/i });
|
||||||
|
await expect(nearMe).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
|
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('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
||||||
|
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
||||||
|
// map's fullscreen button must fall back to a CSS overlay.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// @ts-expect-error deliberate API removal
|
||||||
|
delete Element.prototype.requestFullscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Switch to the map view, then open the map fullscreen.
|
||||||
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||||
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||||
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||||
|
await openFs.click();
|
||||||
|
|
||||||
|
// The button flips to its exit state once the overlay is up.
|
||||||
|
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
||||||
|
await expect(exitFs).toBeVisible();
|
||||||
|
await exitFs.click();
|
||||||
|
await expect(openFs).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||||
|
// 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))];
|
||||||
|
// Compare three schools, not two: a "primary" search can return all-through
|
||||||
|
// schools that classify as secondary, and the chips only appear for the
|
||||||
|
// active phase. With three schools across two phases, the auto-selected
|
||||||
|
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
||||||
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The mobile chart legend renders one chip per school in the active phase.
|
||||||
|
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||||
|
const chips = chipGroup.getByRole('button');
|
||||||
|
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
expect(await chips.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Tapping a chip focuses that school's line; tapping again releases it.
|
||||||
|
await chips.first().click();
|
||||||
|
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await chips.first().click();
|
||||||
|
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rankings page loads a populated table', async ({ page }) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
-2530
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="40" height="40" rx="8" fill="#1a1612"/>
|
|
||||||
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
|
|
||||||
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="20" cy="20" r="3" fill="#e07256"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 374 B |
@@ -1,663 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SchoolCompare | Compare Primary School Performance</title>
|
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Compare primary school KS2 performance across England. Search, filter and compare Reading, Writing and Maths results for thousands of schools."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="keywords"
|
|
||||||
content="school comparison, KS2 results, primary school performance, England schools, SATs results"
|
|
||||||
/>
|
|
||||||
<meta name="author" content="SchoolCompare" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
|
|
||||||
<!-- Analytics -->
|
|
||||||
<script
|
|
||||||
defer
|
|
||||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
|
||||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
|
||||||
></script>
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
|
|
||||||
<!-- Canonical -->
|
|
||||||
<link rel="canonical" href="https://schoolcompare.co.uk/" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content="https://schoolcompare.co.uk/" />
|
|
||||||
<meta
|
|
||||||
property="og:title"
|
|
||||||
content="SchoolCompare | Compare Primary School Performance"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="Compare primary school KS2 performance across England. Search and compare Reading, Writing and Maths results."
|
|
||||||
/>
|
|
||||||
<meta property="og:site_name" content="SchoolCompare" />
|
|
||||||
|
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
<meta name="twitter:url" content="https://schoolcompare.co.uk/" />
|
|
||||||
<meta
|
|
||||||
name="twitter:title"
|
|
||||||
content="SchoolCompare | Compare Primary School Performance"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content="Compare primary school KS2 performance across England."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- JSON-LD Structured Data -->
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebApplication",
|
|
||||||
"name": "SchoolCompare",
|
|
||||||
"url": "https://schoolcompare.co.uk",
|
|
||||||
"description": "Compare primary school KS2 performance across England",
|
|
||||||
"applicationCategory": "EducationalApplication",
|
|
||||||
"operatingSystem": "Web",
|
|
||||||
"offers": {
|
|
||||||
"@type": "Offer",
|
|
||||||
"price": "0",
|
|
||||||
"priceCurrency": "GBP"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": "SchoolCompare",
|
|
||||||
"url": "https://schoolcompare.co.uk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<!-- Leaflet Map Library -->
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
<script
|
|
||||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
||||||
crossorigin=""
|
|
||||||
></script>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="noise-overlay"></div>
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<div class="logo-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 40 40"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="20"
|
|
||||||
cy="20"
|
|
||||||
r="18"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="20" cy="20" r="4" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="logo-text">
|
|
||||||
<span class="logo-title">SchoolCompare</span>
|
|
||||||
<span class="logo-subtitle">schoolcompare.co.uk</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/" class="nav-link active" data-view="home"
|
|
||||||
>Home</a
|
|
||||||
>
|
|
||||||
<a href="/compare" class="nav-link" data-view="compare"
|
|
||||||
>Compare</a
|
|
||||||
>
|
|
||||||
<a href="/rankings" class="nav-link" data-view="rankings"
|
|
||||||
>Rankings</a
|
|
||||||
>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<!-- Home View -->
|
|
||||||
<section id="home-view" class="view active">
|
|
||||||
<div class="hero">
|
|
||||||
<h1 class="hero-title">
|
|
||||||
Compare Primary School Performance
|
|
||||||
</h1>
|
|
||||||
<p class="hero-subtitle">
|
|
||||||
Search and compare KS2 results across England's primary
|
|
||||||
schools
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-section">
|
|
||||||
<div class="search-mode-toggle">
|
|
||||||
<button class="search-mode-btn active" data-mode="name">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="M21 21l-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
Find by Name
|
|
||||||
</button>
|
|
||||||
<button class="search-mode-btn" data-mode="location">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="10" r="3" />
|
|
||||||
</svg>
|
|
||||||
Find by Location
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="name-search-panel" class="search-panel active">
|
|
||||||
<div class="search-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="school-search"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Search primary schools by name..."
|
|
||||||
/>
|
|
||||||
<div class="search-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="M21 21l-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="filter-row">
|
|
||||||
<select
|
|
||||||
id="local-authority-filter"
|
|
||||||
class="filter-select"
|
|
||||||
>
|
|
||||||
<option value="">All Areas</option>
|
|
||||||
</select>
|
|
||||||
<select id="type-filter" class="filter-select">
|
|
||||||
<option value="">All School Types</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="location-search-panel" class="search-panel">
|
|
||||||
<div class="location-input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="postcode-search"
|
|
||||||
class="search-input postcode-input"
|
|
||||||
placeholder="Enter postcode..."
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
id="radius-select"
|
|
||||||
class="filter-select radius-select"
|
|
||||||
>
|
|
||||||
<option value="0.5" selected>1/2 mile</option>
|
|
||||||
<option value="1">1 mile</option>
|
|
||||||
<option value="2">2 miles</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
id="type-filter-location"
|
|
||||||
class="filter-select"
|
|
||||||
>
|
|
||||||
<option value="">All School Types</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
id="location-search-btn"
|
|
||||||
class="btn btn-primary location-btn"
|
|
||||||
>
|
|
||||||
Find Nearby
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="view-toggle" id="view-toggle" style="display: none">
|
|
||||||
<button class="view-toggle-btn active" data-view="list">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<line x1="8" y1="6" x2="21" y2="6" />
|
|
||||||
<line x1="8" y1="12" x2="21" y2="12" />
|
|
||||||
<line x1="8" y1="18" x2="21" y2="18" />
|
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
|
||||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
|
||||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
|
||||||
</svg>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button class="view-toggle-btn" data-view="map">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="10" r="3" />
|
|
||||||
</svg>
|
|
||||||
Map
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="results-container" id="results-container">
|
|
||||||
<div class="results-map" id="results-map"></div>
|
|
||||||
<div class="schools-grid" id="schools-grid">
|
|
||||||
<!-- School cards populated by JS -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Compare View -->
|
|
||||||
<section id="compare-view" class="view">
|
|
||||||
<div class="compare-header">
|
|
||||||
<h2 class="section-title">Compare Primary Schools</h2>
|
|
||||||
<p class="section-subtitle">
|
|
||||||
Select schools to compare their KS2 performance over
|
|
||||||
time
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="compare-search-section">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="compare-search"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Add a school to compare..."
|
|
||||||
/>
|
|
||||||
<div id="compare-results" class="compare-results"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="selected-schools" id="selected-schools">
|
|
||||||
<div class="empty-selection">
|
|
||||||
<div class="empty-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="6"
|
|
||||||
y="10"
|
|
||||||
width="36"
|
|
||||||
height="28"
|
|
||||||
rx="2"
|
|
||||||
/>
|
|
||||||
<path d="M6 18h36" />
|
|
||||||
<circle
|
|
||||||
cx="14"
|
|
||||||
cy="14"
|
|
||||||
r="2"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="22"
|
|
||||||
cy="14"
|
|
||||||
r="2"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>Search and add schools to compare</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="charts-section"
|
|
||||||
id="charts-section"
|
|
||||||
style="display: none"
|
|
||||||
>
|
|
||||||
<div class="metric-selector">
|
|
||||||
<label>Select KS2 Metric:</label>
|
|
||||||
<select id="metric-select" class="filter-select">
|
|
||||||
<optgroup label="Expected Standard">
|
|
||||||
<option value="rwm_expected_pct">
|
|
||||||
Reading, Writing & Maths Combined %
|
|
||||||
</option>
|
|
||||||
<option value="reading_expected_pct">
|
|
||||||
Reading Expected %
|
|
||||||
</option>
|
|
||||||
<option value="writing_expected_pct">
|
|
||||||
Writing Expected %
|
|
||||||
</option>
|
|
||||||
<option value="maths_expected_pct">
|
|
||||||
Maths Expected %
|
|
||||||
</option>
|
|
||||||
<option value="gps_expected_pct">
|
|
||||||
GPS Expected %
|
|
||||||
</option>
|
|
||||||
<option value="science_expected_pct">
|
|
||||||
Science Expected %
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Higher Standard">
|
|
||||||
<option value="rwm_high_pct">
|
|
||||||
RWM Combined Higher %
|
|
||||||
</option>
|
|
||||||
<option value="reading_high_pct">
|
|
||||||
Reading Higher %
|
|
||||||
</option>
|
|
||||||
<option value="writing_high_pct">
|
|
||||||
Writing Higher %
|
|
||||||
</option>
|
|
||||||
<option value="maths_high_pct">
|
|
||||||
Maths Higher %
|
|
||||||
</option>
|
|
||||||
<option value="gps_high_pct">
|
|
||||||
GPS Higher %
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Progress Scores">
|
|
||||||
<option value="reading_progress">
|
|
||||||
Reading Progress
|
|
||||||
</option>
|
|
||||||
<option value="writing_progress">
|
|
||||||
Writing Progress
|
|
||||||
</option>
|
|
||||||
<option value="maths_progress">
|
|
||||||
Maths Progress
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Average Scores">
|
|
||||||
<option value="reading_avg_score">
|
|
||||||
Reading Avg Score
|
|
||||||
</option>
|
|
||||||
<option value="maths_avg_score">
|
|
||||||
Maths Avg Score
|
|
||||||
</option>
|
|
||||||
<option value="gps_avg_score">
|
|
||||||
GPS Avg Score
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Gender Performance">
|
|
||||||
<option value="rwm_expected_boys_pct">
|
|
||||||
RWM Expected % (Boys)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_expected_girls_pct">
|
|
||||||
RWM Expected % (Girls)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_high_boys_pct">
|
|
||||||
RWM Higher % (Boys)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_high_girls_pct">
|
|
||||||
RWM Higher % (Girls)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Equity (Disadvantaged)">
|
|
||||||
<option value="rwm_expected_disadvantaged_pct">
|
|
||||||
RWM Expected % (Disadvantaged)
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="rwm_expected_non_disadvantaged_pct"
|
|
||||||
>
|
|
||||||
RWM Expected % (Non-Disadvantaged)
|
|
||||||
</option>
|
|
||||||
<option value="disadvantaged_gap">
|
|
||||||
Disadvantaged Gap vs National
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="School Context">
|
|
||||||
<option value="disadvantaged_pct">
|
|
||||||
% Disadvantaged Pupils
|
|
||||||
</option>
|
|
||||||
<option value="eal_pct">% EAL Pupils</option>
|
|
||||||
<option value="sen_support_pct">
|
|
||||||
% SEN Support
|
|
||||||
</option>
|
|
||||||
<option value="stability_pct">
|
|
||||||
% Pupil Stability
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="3-Year Trends">
|
|
||||||
<option value="rwm_expected_3yr_pct">
|
|
||||||
RWM Expected % (3-Year Avg)
|
|
||||||
</option>
|
|
||||||
<option value="reading_avg_3yr">
|
|
||||||
Reading Score (3-Year Avg)
|
|
||||||
</option>
|
|
||||||
<option value="maths_avg_3yr">
|
|
||||||
Maths Score (3-Year Avg)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="comparison-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="data-table-container">
|
|
||||||
<table class="data-table" id="comparison-table">
|
|
||||||
<thead>
|
|
||||||
<tr id="table-header"></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Rankings View -->
|
|
||||||
<section id="rankings-view" class="view">
|
|
||||||
<div class="rankings-header">
|
|
||||||
<h2 class="section-title">Primary School Rankings</h2>
|
|
||||||
<p class="section-subtitle">
|
|
||||||
Top performing primary schools ranked by KS2 metric
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rankings-controls">
|
|
||||||
<select id="ranking-area" class="filter-select">
|
|
||||||
<option value="">All Areas</option>
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</select>
|
|
||||||
<select id="ranking-metric" class="filter-select">
|
|
||||||
<optgroup label="Expected Standard">
|
|
||||||
<option value="rwm_expected_pct">
|
|
||||||
Reading, Writing & Maths Combined %
|
|
||||||
</option>
|
|
||||||
<option value="reading_expected_pct">
|
|
||||||
Reading Expected %
|
|
||||||
</option>
|
|
||||||
<option value="writing_expected_pct">
|
|
||||||
Writing Expected %
|
|
||||||
</option>
|
|
||||||
<option value="maths_expected_pct">
|
|
||||||
Maths Expected %
|
|
||||||
</option>
|
|
||||||
<option value="gps_expected_pct">
|
|
||||||
GPS Expected %
|
|
||||||
</option>
|
|
||||||
<option value="science_expected_pct">
|
|
||||||
Science Expected %
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Higher Standard">
|
|
||||||
<option value="rwm_high_pct">
|
|
||||||
RWM Combined Higher %
|
|
||||||
</option>
|
|
||||||
<option value="reading_high_pct">
|
|
||||||
Reading Higher %
|
|
||||||
</option>
|
|
||||||
<option value="writing_high_pct">
|
|
||||||
Writing Higher %
|
|
||||||
</option>
|
|
||||||
<option value="maths_high_pct">
|
|
||||||
Maths Higher %
|
|
||||||
</option>
|
|
||||||
<option value="gps_high_pct">GPS Higher %</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Progress Scores">
|
|
||||||
<option value="reading_progress">
|
|
||||||
Reading Progress
|
|
||||||
</option>
|
|
||||||
<option value="writing_progress">
|
|
||||||
Writing Progress
|
|
||||||
</option>
|
|
||||||
<option value="maths_progress">
|
|
||||||
Maths Progress
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Average Scores">
|
|
||||||
<option value="reading_avg_score">
|
|
||||||
Reading Avg Score
|
|
||||||
</option>
|
|
||||||
<option value="maths_avg_score">
|
|
||||||
Maths Avg Score
|
|
||||||
</option>
|
|
||||||
<option value="gps_avg_score">GPS Avg Score</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Gender Performance">
|
|
||||||
<option value="rwm_expected_boys_pct">
|
|
||||||
RWM Expected % (Boys)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_expected_girls_pct">
|
|
||||||
RWM Expected % (Girls)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_high_boys_pct">
|
|
||||||
RWM Higher % (Boys)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_high_girls_pct">
|
|
||||||
RWM Higher % (Girls)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Equity (Disadvantaged)">
|
|
||||||
<option value="rwm_expected_disadvantaged_pct">
|
|
||||||
RWM Expected % (Disadvantaged)
|
|
||||||
</option>
|
|
||||||
<option value="rwm_expected_non_disadvantaged_pct">
|
|
||||||
RWM Expected % (Non-Disadvantaged)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="3-Year Trends">
|
|
||||||
<option value="rwm_expected_3yr_pct">
|
|
||||||
RWM Expected % (3-Year Avg)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
<select id="ranking-year" class="filter-select">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rankings-list" id="rankings-list">
|
|
||||||
<!-- Rankings populated by JS -->
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- School Detail Modal -->
|
|
||||||
<div class="modal" id="school-modal">
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-content">
|
|
||||||
<button class="modal-close" id="modal-close">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="modal-header">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary modal-compare-btn"
|
|
||||||
id="add-to-compare"
|
|
||||||
>
|
|
||||||
Add to Compare
|
|
||||||
</button>
|
|
||||||
<h2 id="modal-school-name"></h2>
|
|
||||||
<div class="modal-meta" id="modal-meta"></div>
|
|
||||||
<div class="modal-details" id="modal-details"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="modal-chart-container">
|
|
||||||
<canvas id="school-detail-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="modal-stats" id="modal-stats"></div>
|
|
||||||
<div class="modal-map-container" id="modal-map-container">
|
|
||||||
<h4>Location</h4>
|
|
||||||
<div class="modal-map" id="modal-map"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-content">
|
|
||||||
<div class="footer-contact">
|
|
||||||
<a href="mailto:contact@schoolcompare.co.uk">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer-source">
|
|
||||||
<p>
|
|
||||||
Data source:
|
|
||||||
<a
|
|
||||||
href="https://www.compare-school-performance.service.gov.uk/"
|
|
||||||
target="_blank"
|
|
||||||
>UK Government - Compare School Performance</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Allow: /compare
|
|
||||||
Allow: /rankings
|
|
||||||
|
|
||||||
Disallow: /api/
|
|
||||||
|
|
||||||
Sitemap: https://schoolcompare.co.uk/sitemap.xml
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://schoolcompare.co.uk/</loc>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://schoolcompare.co.uk/compare</loc>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://schoolcompare.co.uk/rankings</loc>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
-1903
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
COPY server.py .
|
|
||||||
|
|
||||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8001"]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
FROM alpine:3.19
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
COPY flows/ /flows/
|
|
||||||
COPY docker/kestra-init.sh /kestra-init.sh
|
|
||||||
RUN chmod +x /kestra-init.sh
|
|
||||||
CMD ["/kestra-init.sh"]
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
KESTRA_URL="${KESTRA_URL:-http://kestra:8080}"
|
|
||||||
MAX_WAIT=120
|
|
||||||
|
|
||||||
# Basic auth — set KESTRA_USER / KESTRA_PASSWORD if authentication is enabled
|
|
||||||
AUTH=""
|
|
||||||
if [ -n "$KESTRA_USER" ] && [ -n "$KESTRA_PASSWORD" ]; then
|
|
||||||
AUTH="-u ${KESTRA_USER}:${KESTRA_PASSWORD}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for Kestra API at ${KESTRA_URL}..."
|
|
||||||
elapsed=0
|
|
||||||
until curl -sf $AUTH "${KESTRA_URL}/api/v1/flows/search" > /dev/null 2>&1; do
|
|
||||||
if [ "$elapsed" -ge "$MAX_WAIT" ]; then
|
|
||||||
echo "ERROR: Kestra API not reachable after ${MAX_WAIT}s"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
elapsed=$((elapsed + 5))
|
|
||||||
done
|
|
||||||
echo "Kestra API is ready."
|
|
||||||
|
|
||||||
echo "Importing flows..."
|
|
||||||
|
|
||||||
for f in /flows/*.yml; do
|
|
||||||
name="$(basename "$f")"
|
|
||||||
echo " -> $name"
|
|
||||||
|
|
||||||
http_code=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
|
|
||||||
-X POST "${KESTRA_URL}/api/v1/flows" \
|
|
||||||
-H "Content-Type: application/x-yaml" \
|
|
||||||
--data-binary "@${f}")
|
|
||||||
|
|
||||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
|
||||||
echo " created"
|
|
||||||
elif [ "$http_code" = "409" ]; then
|
|
||||||
ns=$(grep '^namespace:' "$f" | awk '{print $2}')
|
|
||||||
id=$(grep '^id:' "$f" | awk '{print $2}')
|
|
||||||
http_code2=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
|
|
||||||
-X PUT "${KESTRA_URL}/api/v1/flows/${ns}/${id}" \
|
|
||||||
-H "Content-Type: application/x-yaml" \
|
|
||||||
--data-binary "@${f}")
|
|
||||||
if [ "$http_code2" = "200" ] || [ "$http_code2" = "201" ]; then
|
|
||||||
echo " updated"
|
|
||||||
else
|
|
||||||
echo " ERROR updating $name: HTTP $http_code2"
|
|
||||||
cat /tmp/kestra_resp; echo
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " ERROR importing $name: HTTP $http_code"
|
|
||||||
cat /tmp/kestra_resp; echo
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "All flows imported."
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: admissions-annual-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load school admissions data via EES API
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 4 1 7 *" # 1 July annually at 04:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/admissions?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT20M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/admissions?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT15M
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: census-annual-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load School Census (SPC) data via EES API
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 4 1 9 *" # 1 September annually at 04:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/census?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT20M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/census?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT15M
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: finance-annual-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Fetch FBIT financial benchmarking data from DfE API for all schools
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 4 1 12 *" # 1 December annually at 04:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/finance?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT120M # Fetches per-school from API — ~20k schools
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/finance?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 2
|
|
||||||
interval: PT30M
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
id: gias-weekly-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load GIAS (Get Information About Schools) bulk CSV
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: weekly-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 3 * * 0" # Every Sunday at 03:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/gias?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/gias?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
errors:
|
|
||||||
- id: notify-failure
|
|
||||||
type: io.kestra.plugin.core.log.Log
|
|
||||||
message: "GIAS update FAILED: {{ error.message }}"
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT10M
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: idaci-annual-check
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download IoD2019 IDACI file and compute deprivation scores for all schools
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 5 1 1 *" # 1 January annually at 05:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/idaci?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT10M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/idaci?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT60M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 2
|
|
||||||
interval: PT30M
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
id: ks2-reimport
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Re-import KS2 attainment data from bundled CSV files (use after DB wipe)
|
|
||||||
|
|
||||||
# No scheduled trigger — run manually from the Kestra UI when needed.
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: reimport
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/ks2?action=load
|
|
||||||
method: POST
|
|
||||||
allowFailed: false
|
|
||||||
timeout: PT30S # fire-and-forget; backend runs migration in background
|
|
||||||
|
|
||||||
errors:
|
|
||||||
- id: notify-failure
|
|
||||||
type: io.kestra.plugin.core.log.Log
|
|
||||||
message: "KS2 re-import FAILED: {{ error.message }}"
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 2
|
|
||||||
interval: PT5M
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
id: ofsted-monthly-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load Ofsted Monthly Management Information CSV
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: monthly-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 2 1 * *" # 1st of each month at 02:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/ofsted?action=download
|
|
||||||
method: POST
|
|
||||||
allowFailed: false
|
|
||||||
timeout: PT10M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/ofsted?action=load
|
|
||||||
method: POST
|
|
||||||
allowFailed: false
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
errors:
|
|
||||||
- id: notify-failure
|
|
||||||
type: io.kestra.plugin.core.log.Log
|
|
||||||
message: "Ofsted update FAILED: {{ error.message }}"
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT10M
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
id: parent-view-monthly-check
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load Ofsted Parent View open data (released ~3x/year)
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: monthly-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 3 1 * *" # 1st of each month at 03:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/parent_view?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT10M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/parent_view?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT20M
|
|
||||||
|
|
||||||
errors:
|
|
||||||
- id: notify-failure
|
|
||||||
type: io.kestra.plugin.core.log.Log
|
|
||||||
message: "Parent View update FAILED: {{ error.message }}"
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT10M
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: phonics-annual-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load Phonics Screening Check data via EES API
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 5 1 9 *" # 1 September annually at 05:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/phonics?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT20M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/phonics?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT15M
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
id: sen-detail-annual-update
|
|
||||||
namespace: schoolcompare.data
|
|
||||||
description: Download and load SEN primary need breakdown via EES API
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- id: annual-schedule
|
|
||||||
type: io.kestra.plugin.core.trigger.Schedule
|
|
||||||
cron: "0 4 15 9 *" # 15 September annually at 04:00
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- id: download
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/sen_detail?action=download
|
|
||||||
method: POST
|
|
||||||
timeout: PT20M
|
|
||||||
|
|
||||||
- id: load
|
|
||||||
type: io.kestra.plugin.core.http.Request
|
|
||||||
uri: http://integrator:8001/run/sen_detail?action=load
|
|
||||||
method: POST
|
|
||||||
timeout: PT30M
|
|
||||||
|
|
||||||
retry:
|
|
||||||
type: constant
|
|
||||||
maxAttempts: 3
|
|
||||||
interval: PT15M
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
fastapi==0.115.0
|
|
||||||
uvicorn[standard]==0.30.6
|
|
||||||
requests==2.32.3
|
|
||||||
pandas==2.2.3
|
|
||||||
openpyxl==3.1.5
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
sqlalchemy==2.0.35
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"""Configuration for the data integrator."""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get(
|
|
||||||
"DATABASE_URL",
|
|
||||||
"postgresql://schoolcompare:schoolcompare@db:5432/schoolcompare",
|
|
||||||
)
|
|
||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
|
||||||
SUPPLEMENTARY_DIR = DATA_DIR / "supplementary"
|
|
||||||
|
|
||||||
BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:80")
|
|
||||||
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "changeme")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Database connection for the integrator."""
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from config import DATABASE_URL
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def get_session():
|
|
||||||
session = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
session.commit()
|
|
||||||
except Exception:
|
|
||||||
session.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
School Admissions data downloader and loader.
|
|
||||||
|
|
||||||
Source: EES publication "primary-and-secondary-school-applications-and-offers"
|
|
||||||
Content API release ZIP → supporting-files/AppsandOffers_*_SchoolLevel*.csv
|
|
||||||
Update: Annual (June/July post-offer round)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
from sources.ees import download_release_zip_csv
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "admissions"
|
|
||||||
PUBLICATION_SLUG = "primary-and-secondary-school-applications-and-offers"
|
|
||||||
|
|
||||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", "Z", ""}
|
|
||||||
|
|
||||||
# Maps actual CSV column names → internal field names
|
|
||||||
COLUMN_MAP = {
|
|
||||||
# School identifier
|
|
||||||
"school_urn": "urn",
|
|
||||||
# Year — e.g. 202526 → 2025
|
|
||||||
"time_period": "time_period_raw",
|
|
||||||
# PAN (places offered)
|
|
||||||
"total_number_places_offered": "pan",
|
|
||||||
# Applications (total times put as any preference)
|
|
||||||
"times_put_as_any_preferred_school": "total_applications",
|
|
||||||
# 1st-preference applications
|
|
||||||
"times_put_as_1st_preference": "times_1st_pref",
|
|
||||||
# 1st-preference offers
|
|
||||||
"number_1st_preference_offers": "offers_1st_pref",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest_file = dest / "admissions_school_level_latest.csv"
|
|
||||||
return download_release_zip_csv(
|
|
||||||
PUBLICATION_SLUG,
|
|
||||||
dest_file,
|
|
||||||
zip_member_keyword="schoollevel",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_int(val) -> int | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().upper().replace(",", "")
|
|
||||||
if s in NULL_VALUES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(float(s))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pct(val) -> float | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().upper().replace("%", "")
|
|
||||||
if s in NULL_VALUES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No admissions CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" Admissions: loading {path} ...")
|
|
||||||
df = pd.read_csv(path, encoding="utf-8-sig", low_memory=False)
|
|
||||||
|
|
||||||
# Rename columns we care about
|
|
||||||
df.rename(columns=COLUMN_MAP, inplace=True)
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
# Filter to primary schools only
|
|
||||||
if "school_phase" in df.columns:
|
|
||||||
df = df[df["school_phase"].str.lower() == "primary"]
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
# Derive year from time_period (e.g. 202526 → 2025)
|
|
||||||
def _extract_year(val) -> int | None:
|
|
||||||
s = str(val).strip()
|
|
||||||
m = re.match(r"(\d{4})\d{2}", s)
|
|
||||||
if m:
|
|
||||||
return int(m.group(1))
|
|
||||||
m2 = re.search(r"20(\d{2})", s)
|
|
||||||
if m2:
|
|
||||||
return int("20" + m2.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "time_period_raw" in df.columns:
|
|
||||||
df["year"] = df["time_period_raw"].apply(_extract_year)
|
|
||||||
else:
|
|
||||||
year_m = re.search(r"20(\d{2})", path.stem)
|
|
||||||
df["year"] = int("20" + year_m.group(1)) if year_m else None
|
|
||||||
|
|
||||||
df = df.dropna(subset=["year"])
|
|
||||||
df["year"] = df["year"].astype(int)
|
|
||||||
|
|
||||||
# Keep most recent year per school (file may contain multiple years)
|
|
||||||
df = df.sort_values("year", ascending=False).groupby("urn").first().reset_index()
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
year = int(row["year"])
|
|
||||||
|
|
||||||
pan = _parse_int(row.get("pan"))
|
|
||||||
total_apps = _parse_int(row.get("total_applications"))
|
|
||||||
times_1st = _parse_int(row.get("times_1st_pref"))
|
|
||||||
offers_1st = _parse_int(row.get("offers_1st_pref"))
|
|
||||||
|
|
||||||
# % of 1st-preference applicants who received an offer
|
|
||||||
if times_1st and times_1st > 0 and offers_1st is not None:
|
|
||||||
pct_1st = round(offers_1st / times_1st * 100, 1)
|
|
||||||
else:
|
|
||||||
pct_1st = None
|
|
||||||
|
|
||||||
oversubscribed = (
|
|
||||||
True if (pan and times_1st and times_1st > pan) else
|
|
||||||
False if (pan and times_1st and times_1st <= pan) else
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO school_admissions
|
|
||||||
(urn, year, published_admission_number, total_applications,
|
|
||||||
first_preference_offers_pct, oversubscribed)
|
|
||||||
VALUES (:urn, :year, :pan, :total_apps, :pct_1st, :oversubscribed)
|
|
||||||
ON CONFLICT (urn, year) DO UPDATE SET
|
|
||||||
published_admission_number = EXCLUDED.published_admission_number,
|
|
||||||
total_applications = EXCLUDED.total_applications,
|
|
||||||
first_preference_offers_pct = EXCLUDED.first_preference_offers_pct,
|
|
||||||
oversubscribed = EXCLUDED.oversubscribed
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": urn, "year": year, "pan": pan,
|
|
||||||
"total_apps": total_apps, "pct_1st": pct_1st,
|
|
||||||
"oversubscribed": oversubscribed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
print(f" Processed {inserted} records...")
|
|
||||||
|
|
||||||
print(f" Admissions: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""
|
|
||||||
School Census (SPC) downloader and loader.
|
|
||||||
|
|
||||||
Source: EES publication "schools-pupils-and-their-characteristics"
|
|
||||||
Update: Annual (June)
|
|
||||||
Adds: class_size_avg, ethnicity breakdown by school
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
from sources.ees import get_latest_csv_url, download_csv
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "census"
|
|
||||||
PUBLICATION_SLUG = "schools-pupils-and-their-characteristics"
|
|
||||||
|
|
||||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
|
|
||||||
|
|
||||||
COLUMN_MAP = {
|
|
||||||
"URN": "urn",
|
|
||||||
"urn": "urn",
|
|
||||||
"YEAR": "year",
|
|
||||||
"Year": "year",
|
|
||||||
# Class size
|
|
||||||
"average_class_size": "class_size_avg",
|
|
||||||
"AVCLAS": "class_size_avg",
|
|
||||||
"avg_class_size": "class_size_avg",
|
|
||||||
# Ethnicity — DfE uses ethnicity major group percentages
|
|
||||||
"perc_white": "ethnicity_white_pct",
|
|
||||||
"perc_asian": "ethnicity_asian_pct",
|
|
||||||
"perc_black": "ethnicity_black_pct",
|
|
||||||
"perc_mixed": "ethnicity_mixed_pct",
|
|
||||||
"perc_other_ethnic": "ethnicity_other_pct",
|
|
||||||
"PTWHITE": "ethnicity_white_pct",
|
|
||||||
"PTASIAN": "ethnicity_asian_pct",
|
|
||||||
"PTBLACK": "ethnicity_black_pct",
|
|
||||||
"PTMIXED": "ethnicity_mixed_pct",
|
|
||||||
"PTOTHER": "ethnicity_other_pct",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
|
||||||
if not url:
|
|
||||||
raise RuntimeError(f"Could not find CSV URL for census publication")
|
|
||||||
|
|
||||||
filename = url.split("/")[-1].split("?")[0] or "census_latest.csv"
|
|
||||||
return download_csv(url, dest / filename)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pct(val) -> float | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().upper().replace("%", "")
|
|
||||||
if s in NULL_VALUES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No census CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" Census: loading {path} ...")
|
|
||||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
|
||||||
df.rename(columns=COLUMN_MAP, inplace=True)
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
year = None
|
|
||||||
m = re.search(r"20(\d{2})", path.stem)
|
|
||||||
if m:
|
|
||||||
year = int("20" + m.group(1))
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
|
|
||||||
if not row_year:
|
|
||||||
continue
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO school_census
|
|
||||||
(urn, year, class_size_avg,
|
|
||||||
ethnicity_white_pct, ethnicity_asian_pct, ethnicity_black_pct,
|
|
||||||
ethnicity_mixed_pct, ethnicity_other_pct)
|
|
||||||
VALUES (:urn, :year, :class_size_avg,
|
|
||||||
:white, :asian, :black, :mixed, :other)
|
|
||||||
ON CONFLICT (urn, year) DO UPDATE SET
|
|
||||||
class_size_avg = EXCLUDED.class_size_avg,
|
|
||||||
ethnicity_white_pct = EXCLUDED.ethnicity_white_pct,
|
|
||||||
ethnicity_asian_pct = EXCLUDED.ethnicity_asian_pct,
|
|
||||||
ethnicity_black_pct = EXCLUDED.ethnicity_black_pct,
|
|
||||||
ethnicity_mixed_pct = EXCLUDED.ethnicity_mixed_pct,
|
|
||||||
ethnicity_other_pct = EXCLUDED.ethnicity_other_pct
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": urn,
|
|
||||||
"year": row_year,
|
|
||||||
"class_size_avg": _parse_pct(row.get("class_size_avg")),
|
|
||||||
"white": _parse_pct(row.get("ethnicity_white_pct")),
|
|
||||||
"asian": _parse_pct(row.get("ethnicity_asian_pct")),
|
|
||||||
"black": _parse_pct(row.get("ethnicity_black_pct")),
|
|
||||||
"mixed": _parse_pct(row.get("ethnicity_mixed_pct")),
|
|
||||||
"other": _parse_pct(row.get("ethnicity_other_pct")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" Census: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared EES (Explore Education Statistics) API client.
|
|
||||||
|
|
||||||
Two APIs are available:
|
|
||||||
- Statistics API: https://api.education.gov.uk/statistics/v1 (only ~13 publications)
|
|
||||||
- Content API: https://content.explore-education-statistics.service.gov.uk/api
|
|
||||||
Covers all publications; use this for admissions and other data not in the stats API.
|
|
||||||
Download all files for a release as a ZIP from /api/releases/{id}/files.
|
|
||||||
"""
|
|
||||||
import io
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
STATS_API_BASE = "https://api.education.gov.uk/statistics/v1"
|
|
||||||
CONTENT_API_BASE = "https://content.explore-education-statistics.service.gov.uk/api"
|
|
||||||
TIMEOUT = 60
|
|
||||||
|
|
||||||
|
|
||||||
def get_publication_files(publication_slug: str) -> list[dict]:
|
|
||||||
"""Return list of data-set file descriptors for a publication (statistics API)."""
|
|
||||||
url = f"{STATS_API_BASE}/publications/{publication_slug}/data-set-files"
|
|
||||||
resp = requests.get(url, timeout=TIMEOUT)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json().get("results", [])
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Find the most recent CSV download URL for a publication (statistics API).
|
|
||||||
Optionally filter by a keyword in the file name.
|
|
||||||
"""
|
|
||||||
files = get_publication_files(publication_slug)
|
|
||||||
for entry in files:
|
|
||||||
name = entry.get("name", "").lower()
|
|
||||||
if keyword and keyword.lower() not in name:
|
|
||||||
continue
|
|
||||||
csv_url = entry.get("csvDownloadUrl") or entry.get("file", {}).get("url")
|
|
||||||
if csv_url:
|
|
||||||
return csv_url
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_content_release_id(publication_slug: str) -> str:
|
|
||||||
"""Return the latest release ID for a publication via the content API."""
|
|
||||||
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases/latest"
|
|
||||||
resp = requests.get(url, timeout=TIMEOUT)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def download_release_zip_csv(
|
|
||||||
publication_slug: str,
|
|
||||||
dest_path: Path,
|
|
||||||
zip_member_keyword: str = "",
|
|
||||||
) -> Path:
|
|
||||||
"""
|
|
||||||
Download the full-release ZIP from the EES content API and extract one CSV.
|
|
||||||
|
|
||||||
If zip_member_keyword is given, the first member whose path contains that
|
|
||||||
keyword (case-insensitive) is extracted; otherwise the first .csv found is used.
|
|
||||||
Returns dest_path (the extracted CSV file).
|
|
||||||
"""
|
|
||||||
if dest_path.exists():
|
|
||||||
print(f" EES: {dest_path.name} already exists, skipping.")
|
|
||||||
return dest_path
|
|
||||||
|
|
||||||
release_id = get_content_release_id(publication_slug)
|
|
||||||
zip_url = f"{CONTENT_API_BASE}/releases/{release_id}/files"
|
|
||||||
print(f" EES: downloading release ZIP for '{publication_slug}' ...")
|
|
||||||
resp = requests.get(zip_url, timeout=300, stream=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
data = b"".join(resp.iter_content(chunk_size=65536))
|
|
||||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
||||||
members = z.namelist()
|
|
||||||
target = None
|
|
||||||
kw = zip_member_keyword.lower()
|
|
||||||
for m in members:
|
|
||||||
if m.endswith(".csv") and (not kw or kw in m.lower()):
|
|
||||||
target = m
|
|
||||||
break
|
|
||||||
if not target:
|
|
||||||
raise ValueError(
|
|
||||||
f"No CSV matching '{zip_member_keyword}' in ZIP. Members: {members}"
|
|
||||||
)
|
|
||||||
print(f" EES: extracting '{target}' ...")
|
|
||||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with z.open(target) as src, open(dest_path, "wb") as dst:
|
|
||||||
dst.write(src.read())
|
|
||||||
|
|
||||||
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
|
|
||||||
return dest_path
|
|
||||||
|
|
||||||
|
|
||||||
def download_csv(url: str, dest_path: Path) -> Path:
|
|
||||||
"""Download a CSV from EES to dest_path."""
|
|
||||||
if dest_path.exists():
|
|
||||||
print(f" EES: {dest_path.name} already exists, skipping.")
|
|
||||||
return dest_path
|
|
||||||
print(f" EES: downloading {url} ...")
|
|
||||||
resp = requests.get(url, timeout=300, stream=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(dest_path, "wb") as f:
|
|
||||||
for chunk in resp.iter_content(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
|
|
||||||
return dest_path
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
FBIT (Financial Benchmarking and Insights Tool) financial data loader.
|
|
||||||
|
|
||||||
Source: https://schools-financial-benchmarking.service.gov.uk/api/
|
|
||||||
Update: Annual (December — data for the prior financial year)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "finance"
|
|
||||||
API_BASE = "https://schools-financial-benchmarking.service.gov.uk/api"
|
|
||||||
RATE_LIMIT_DELAY = 0.1 # seconds between requests
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
"""
|
|
||||||
Fetch per-URN financial data from FBIT API and save as CSV.
|
|
||||||
Batches all school URNs from the database.
|
|
||||||
"""
|
|
||||||
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Determine year from API (use current year minus 1 for completed financials)
|
|
||||||
from datetime import date
|
|
||||||
year = date.today().year - 1
|
|
||||||
dest_file = dest / f"fbit_{year}.csv"
|
|
||||||
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" Finance: {dest_file.name} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
# Get all URNs from the database
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
rows = session.execute(text("SELECT urn FROM schools")).fetchall()
|
|
||||||
urns = [r[0] for r in rows]
|
|
||||||
print(f" Finance: fetching FBIT data for {len(urns)} schools (year {year}) ...")
|
|
||||||
|
|
||||||
records = []
|
|
||||||
errors = 0
|
|
||||||
for i, urn in enumerate(urns):
|
|
||||||
if i % 500 == 0:
|
|
||||||
print(f" {i}/{len(urns)} ...")
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
f"{API_BASE}/schoolFinancialDataObject/{urn}",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
if data:
|
|
||||||
records.append({
|
|
||||||
"urn": urn,
|
|
||||||
"year": year,
|
|
||||||
"per_pupil_spend": data.get("totalExpenditure") and
|
|
||||||
data.get("numberOfPupils") and
|
|
||||||
round(data["totalExpenditure"] / data["numberOfPupils"], 2),
|
|
||||||
"staff_cost_pct": data.get("staffCostPercent"),
|
|
||||||
"teacher_cost_pct": data.get("teachingStaffCostPercent"),
|
|
||||||
"support_staff_cost_pct": data.get("educationSupportStaffCostPercent"),
|
|
||||||
"premises_cost_pct": data.get("premisesStaffCostPercent"),
|
|
||||||
})
|
|
||||||
elif resp.status_code not in (404, 400):
|
|
||||||
errors += 1
|
|
||||||
except Exception:
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
time.sleep(RATE_LIMIT_DELAY)
|
|
||||||
|
|
||||||
df = pd.DataFrame(records)
|
|
||||||
df.to_csv(dest_file, index=False)
|
|
||||||
print(f" Finance: saved {len(records)} records to {dest_file} ({errors} errors)")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("fbit_*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No finance CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" Finance: loading {path} ...")
|
|
||||||
df = pd.read_csv(path)
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO school_finance
|
|
||||||
(urn, year, per_pupil_spend, staff_cost_pct, teacher_cost_pct,
|
|
||||||
support_staff_cost_pct, premises_cost_pct)
|
|
||||||
VALUES (:urn, :year, :per_pupil, :staff, :teacher, :support, :premises)
|
|
||||||
ON CONFLICT (urn, year) DO UPDATE SET
|
|
||||||
per_pupil_spend = EXCLUDED.per_pupil_spend,
|
|
||||||
staff_cost_pct = EXCLUDED.staff_cost_pct,
|
|
||||||
teacher_cost_pct = EXCLUDED.teacher_cost_pct,
|
|
||||||
support_staff_cost_pct = EXCLUDED.support_staff_cost_pct,
|
|
||||||
premises_cost_pct = EXCLUDED.premises_cost_pct
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": int(row["urn"]),
|
|
||||||
"year": int(row["year"]),
|
|
||||||
"per_pupil": float(row["per_pupil_spend"]) if pd.notna(row.get("per_pupil_spend")) else None,
|
|
||||||
"staff": float(row["staff_cost_pct"]) if pd.notna(row.get("staff_cost_pct")) else None,
|
|
||||||
"teacher": float(row["teacher_cost_pct"]) if pd.notna(row.get("teacher_cost_pct")) else None,
|
|
||||||
"support": float(row["support_staff_cost_pct"]) if pd.notna(row.get("support_staff_cost_pct")) else None,
|
|
||||||
"premises": float(row["premises_cost_pct"]) if pd.notna(row.get("premises_cost_pct")) else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 2000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" Finance: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""
|
|
||||||
GIAS (Get Information About Schools) bulk CSV downloader and loader.
|
|
||||||
|
|
||||||
Source: https://get-information-schools.service.gov.uk/Downloads
|
|
||||||
Update: Daily; we refresh weekly.
|
|
||||||
Adds: website, headteacher_name, capacity, trust_name, trust_uid, gender, nursery_provision
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "gias"
|
|
||||||
|
|
||||||
# GIAS bulk download URL — date is injected at runtime
|
|
||||||
GIAS_URL_TEMPLATE = "https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/public/edubasealldata{date}.csv"
|
|
||||||
|
|
||||||
COLUMN_MAP = {
|
|
||||||
"URN": "urn",
|
|
||||||
"SchoolWebsite": "website",
|
|
||||||
"SchoolCapacity": "capacity",
|
|
||||||
"TrustName": "trust_name",
|
|
||||||
"TrustUID": "trust_uid",
|
|
||||||
"Gender (name)": "gender",
|
|
||||||
"NurseryProvision (name)": "nursery_provision_raw",
|
|
||||||
"HeadTitle": "head_title",
|
|
||||||
"HeadFirstName": "head_first",
|
|
||||||
"HeadLastName": "head_last",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
today = date.today().strftime("%Y%m%d")
|
|
||||||
url = GIAS_URL_TEMPLATE.format(date=today)
|
|
||||||
filename = f"gias_{today}.csv"
|
|
||||||
dest_file = dest / filename
|
|
||||||
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" GIAS: {filename} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
print(f" GIAS: downloading {url} ...")
|
|
||||||
resp = requests.get(url, timeout=300, stream=True)
|
|
||||||
|
|
||||||
# GIAS may not have today's file yet — fall back to yesterday
|
|
||||||
if resp.status_code == 404:
|
|
||||||
from datetime import timedelta
|
|
||||||
yesterday = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
|
|
||||||
url = GIAS_URL_TEMPLATE.format(date=yesterday)
|
|
||||||
filename = f"gias_{yesterday}.csv"
|
|
||||||
dest_file = dest / filename
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" GIAS: {filename} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
resp = requests.get(url, timeout=300, stream=True)
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
with open(dest_file, "wb") as f:
|
|
||||||
for chunk in resp.iter_content(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
print(f" GIAS: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("gias_*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No GIAS CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" GIAS: loading {path} ...")
|
|
||||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
|
||||||
df.rename(columns=COLUMN_MAP, inplace=True)
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
# Build headteacher_name from parts
|
|
||||||
def build_name(row):
|
|
||||||
parts = [
|
|
||||||
str(row.get("head_title", "") or "").strip(),
|
|
||||||
str(row.get("head_first", "") or "").strip(),
|
|
||||||
str(row.get("head_last", "") or "").strip(),
|
|
||||||
]
|
|
||||||
return " ".join(p for p in parts if p) or None
|
|
||||||
|
|
||||||
df["headteacher_name"] = df.apply(build_name, axis=1)
|
|
||||||
df["nursery_provision"] = df.get("nursery_provision_raw", pd.Series()).apply(
|
|
||||||
lambda v: True if str(v).strip().lower().startswith("has") else False if pd.notna(v) else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean_str(val):
|
|
||||||
s = str(val).strip() if pd.notna(val) else None
|
|
||||||
return s if s and s.lower() not in ("nan", "none", "") else None
|
|
||||||
|
|
||||||
updated = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
UPDATE schools SET
|
|
||||||
website = :website,
|
|
||||||
headteacher_name = :headteacher_name,
|
|
||||||
capacity = :capacity,
|
|
||||||
trust_name = :trust_name,
|
|
||||||
trust_uid = :trust_uid,
|
|
||||||
gender = :gender,
|
|
||||||
nursery_provision = :nursery_provision
|
|
||||||
WHERE urn = :urn
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": urn,
|
|
||||||
"website": clean_str(row.get("website")),
|
|
||||||
"headteacher_name": row.get("headteacher_name"),
|
|
||||||
"capacity": int(row["capacity"]) if pd.notna(row.get("capacity")) and str(row.get("capacity")).strip().isdigit() else None,
|
|
||||||
"trust_name": clean_str(row.get("trust_name")),
|
|
||||||
"trust_uid": clean_str(row.get("trust_uid")),
|
|
||||||
"gender": clean_str(row.get("gender")),
|
|
||||||
"nursery_provision": row.get("nursery_provision"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
updated += 1
|
|
||||||
if updated % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
print(f" Updated {updated} schools...")
|
|
||||||
|
|
||||||
print(f" GIAS: updated {updated} school records")
|
|
||||||
return {"inserted": 0, "updated": updated, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
path = download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
IDACI (Income Deprivation Affecting Children Index) loader.
|
|
||||||
|
|
||||||
Source: English Indices of Deprivation 2019
|
|
||||||
https://www.gov.uk/government/statistics/english-indices-of-deprivation-2019
|
|
||||||
|
|
||||||
This is a one-time download (5-yearly release). We join school postcodes to LSOAs
|
|
||||||
via postcodes.io, then look up IDACI scores from the IoD2019 file.
|
|
||||||
|
|
||||||
Update: ~5-yearly (next release expected 2025/26)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "idaci"
|
|
||||||
|
|
||||||
# IoD 2019 supplementary data — "Income Deprivation Affecting Children Index (IDACI)"
|
|
||||||
IOD_2019_URL = (
|
|
||||||
"https://assets.publishing.service.gov.uk/government/uploads/system/uploads/"
|
|
||||||
"attachment_data/file/833970/File_1_-_IMD2019_Index_of_Multiple_Deprivation.xlsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
POSTCODES_IO_BATCH = "https://api.postcodes.io/postcodes"
|
|
||||||
BATCH_SIZE = 100
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
filename = "iod2019_idaci.xlsx"
|
|
||||||
dest_file = dest / filename
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" IDACI: {filename} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
print(f" IDACI: downloading IoD2019 file ...")
|
|
||||||
resp = requests.get(IOD_2019_URL, timeout=300, stream=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
with open(dest_file, "wb") as f:
|
|
||||||
for chunk in resp.iter_content(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
print(f" IDACI: saved {dest_file}")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
|
|
||||||
def _postcode_to_lsoa(postcodes: list[str]) -> dict[str, str]:
|
|
||||||
"""Batch-resolve postcodes to LSOA codes via postcodes.io."""
|
|
||||||
result = {}
|
|
||||||
valid = [p.strip().upper() for p in postcodes if p and len(str(p).strip()) >= 5]
|
|
||||||
valid = list(set(valid))
|
|
||||||
|
|
||||||
for i in range(0, len(valid), BATCH_SIZE):
|
|
||||||
batch = valid[i:i + BATCH_SIZE]
|
|
||||||
try:
|
|
||||||
resp = requests.post(POSTCODES_IO_BATCH, json={"postcodes": batch}, timeout=30)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
for item in resp.json().get("result", []):
|
|
||||||
if item and item.get("result"):
|
|
||||||
lsoa = item["result"].get("lsoa")
|
|
||||||
if lsoa:
|
|
||||||
result[item["query"].upper()] = lsoa
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: postcodes.io batch failed: {e}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
|
|
||||||
if path is None:
|
|
||||||
files = sorted(dest.glob("*.xlsx"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No IDACI file found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" IDACI: loading IoD2019 from {path} ...")
|
|
||||||
|
|
||||||
# IoD2019 File 1 — sheet "IoD2019 IDACI" or similar
|
|
||||||
try:
|
|
||||||
iod_df = pd.read_excel(path, sheet_name=None)
|
|
||||||
# Find sheet with IDACI data
|
|
||||||
idaci_sheet = None
|
|
||||||
for name, df in iod_df.items():
|
|
||||||
if "IDACI" in name.upper() or "IDACI" in str(df.columns.tolist()).upper():
|
|
||||||
idaci_sheet = name
|
|
||||||
break
|
|
||||||
if idaci_sheet is None:
|
|
||||||
idaci_sheet = list(iod_df.keys())[0]
|
|
||||||
df_iod = iod_df[idaci_sheet]
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Could not read IoD2019 file: {e}")
|
|
||||||
|
|
||||||
# Normalise column names — IoD2019 uses specific headers
|
|
||||||
col_lsoa = next((c for c in df_iod.columns if "LSOA" in str(c).upper() and "code" in str(c).lower()), None)
|
|
||||||
col_score = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "score" in str(c).lower()), None)
|
|
||||||
col_rank = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "rank" in str(c).lower()), None)
|
|
||||||
|
|
||||||
if not col_lsoa or not col_score:
|
|
||||||
print(f" IDACI columns available: {list(df_iod.columns)[:20]}")
|
|
||||||
raise ValueError("Could not find LSOA code or IDACI score columns")
|
|
||||||
|
|
||||||
df_iod = df_iod[[col_lsoa, col_score]].copy()
|
|
||||||
df_iod.columns = ["lsoa_code", "idaci_score"]
|
|
||||||
df_iod = df_iod.dropna()
|
|
||||||
|
|
||||||
# Compute decile from rank (or from score distribution)
|
|
||||||
total = len(df_iod)
|
|
||||||
df_iod = df_iod.sort_values("idaci_score", ascending=False)
|
|
||||||
df_iod["idaci_decile"] = (pd.qcut(df_iod["idaci_score"], 10, labels=False) + 1).astype(int)
|
|
||||||
# Decile 1 = most deprived (highest IDACI score)
|
|
||||||
df_iod["idaci_decile"] = 11 - df_iod["idaci_decile"]
|
|
||||||
|
|
||||||
lsoa_lookup = df_iod.set_index("lsoa_code")[["idaci_score", "idaci_decile"]].to_dict("index")
|
|
||||||
print(f" IDACI: loaded {len(lsoa_lookup)} LSOA records")
|
|
||||||
|
|
||||||
# Fetch all school postcodes from the database
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
rows = session.execute(text("SELECT urn, postcode FROM schools WHERE postcode IS NOT NULL")).fetchall()
|
|
||||||
|
|
||||||
postcodes = [r[1] for r in rows]
|
|
||||||
print(f" IDACI: resolving {len(postcodes)} postcodes via postcodes.io ...")
|
|
||||||
pc_to_lsoa = _postcode_to_lsoa(postcodes)
|
|
||||||
print(f" IDACI: resolved {len(pc_to_lsoa)} postcodes to LSOAs")
|
|
||||||
|
|
||||||
inserted = skipped = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for urn, postcode in rows:
|
|
||||||
lsoa = pc_to_lsoa.get(str(postcode).strip().upper())
|
|
||||||
if not lsoa:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
iod = lsoa_lookup.get(lsoa)
|
|
||||||
if not iod:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO school_deprivation (urn, lsoa_code, idaci_score, idaci_decile)
|
|
||||||
VALUES (:urn, :lsoa, :score, :decile)
|
|
||||||
ON CONFLICT (urn) DO UPDATE SET
|
|
||||||
lsoa_code = EXCLUDED.lsoa_code,
|
|
||||||
idaci_score = EXCLUDED.idaci_score,
|
|
||||||
idaci_decile = EXCLUDED.idaci_decile
|
|
||||||
"""),
|
|
||||||
{"urn": urn, "lsoa": lsoa, "score": float(iod["idaci_score"]), "decile": int(iod["idaci_decile"])},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 2000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" IDACI: upserted {inserted}, skipped {skipped}")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": skipped}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""
|
|
||||||
KS2 attainment data re-importer.
|
|
||||||
|
|
||||||
Triggers a full re-import of the KS2 CSV data by calling the backend's
|
|
||||||
admin endpoint. The backend owns the migration logic and CSV column mappings;
|
|
||||||
this module is a thin trigger so the re-import can be orchestrated via Kestra
|
|
||||||
like all other data sources.
|
|
||||||
|
|
||||||
The CSV files must already be present in the data volume under
|
|
||||||
/data/{year}/england_ks2final.csv
|
|
||||||
(populated at deploy time from the repo's data/ directory).
|
|
||||||
"""
|
|
||||||
import requests
|
|
||||||
from config import BACKEND_URL, ADMIN_API_KEY
|
|
||||||
|
|
||||||
HEADERS = {"X-API-Key": ADMIN_API_KEY}
|
|
||||||
|
|
||||||
|
|
||||||
def download():
|
|
||||||
"""No download step — CSVs are shipped with the repo."""
|
|
||||||
print("KS2 CSVs are bundled in the data volume; no download needed.")
|
|
||||||
return {"skipped": True}
|
|
||||||
|
|
||||||
|
|
||||||
def load():
|
|
||||||
"""Trigger KS2 re-import on the backend and return immediately.
|
|
||||||
|
|
||||||
The migration (including geocoding) runs as a background thread on the
|
|
||||||
backend and can take up to an hour. Poll GET /api/admin/reimport-ks2/status
|
|
||||||
to check progress, or simply wait for schools to appear in the UI.
|
|
||||||
"""
|
|
||||||
url = f"{BACKEND_URL}/api/admin/reimport-ks2?geocode=true"
|
|
||||||
print(f"POST {url}")
|
|
||||||
resp = requests.post(url, headers=HEADERS, timeout=30)
|
|
||||||
resp.raise_for_status()
|
|
||||||
result = resp.json()
|
|
||||||
print(f"Result: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download()
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load()
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
"""
|
|
||||||
Ofsted Monthly Management Information CSV downloader and loader.
|
|
||||||
|
|
||||||
Source: https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes
|
|
||||||
Update: Monthly (released ~2 weeks into each month)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import date, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
|
|
||||||
# Current Ofsted MI download URL — update this when Ofsted releases a new file.
|
|
||||||
# The URL follows a predictable pattern; we attempt to discover it from the GOV.UK page.
|
|
||||||
GOV_UK_PAGE = "https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes"
|
|
||||||
|
|
||||||
# Column name → internal field, listed in priority order per field.
|
|
||||||
# First matching column wins; later entries are fallbacks for older file formats.
|
|
||||||
COLUMN_PRIORITY = {
|
|
||||||
"urn": ["URN", "Urn", "urn"],
|
|
||||||
"inspection_date": [
|
|
||||||
"Inspection start date of latest OEIF graded inspection",
|
|
||||||
"Inspection start date",
|
|
||||||
"Inspection date",
|
|
||||||
"InspectionDate",
|
|
||||||
],
|
|
||||||
"publication_date": [
|
|
||||||
"Publication date of latest OEIF graded inspection",
|
|
||||||
"Publication date",
|
|
||||||
"PublicationDate",
|
|
||||||
],
|
|
||||||
"inspection_type": [
|
|
||||||
"Inspection type of latest OEIF graded inspection",
|
|
||||||
"Inspection type",
|
|
||||||
"InspectionType",
|
|
||||||
],
|
|
||||||
"overall_effectiveness": [
|
|
||||||
"Latest OEIF overall effectiveness",
|
|
||||||
"Overall effectiveness",
|
|
||||||
"OverallEffectiveness",
|
|
||||||
],
|
|
||||||
"quality_of_education": [
|
|
||||||
"Latest OEIF quality of education",
|
|
||||||
"Quality of education",
|
|
||||||
"QualityOfEducation",
|
|
||||||
],
|
|
||||||
"behaviour_attitudes": [
|
|
||||||
"Latest OEIF behaviour and attitudes",
|
|
||||||
"Behaviour and attitudes",
|
|
||||||
"BehaviourAndAttitudes",
|
|
||||||
],
|
|
||||||
"personal_development": [
|
|
||||||
"Latest OEIF personal development",
|
|
||||||
"Personal development",
|
|
||||||
"PersonalDevelopment",
|
|
||||||
],
|
|
||||||
"leadership_management": [
|
|
||||||
"Latest OEIF effectiveness of leadership and management",
|
|
||||||
"Leadership and management",
|
|
||||||
"LeadershipAndManagement",
|
|
||||||
],
|
|
||||||
"early_years_provision": [
|
|
||||||
"Latest OEIF early years provision (where applicable)",
|
|
||||||
"Early years provision",
|
|
||||||
"EarlyYearsProvision",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
GRADE_MAP = {
|
|
||||||
"Outstanding": 1, "1": 1, 1: 1,
|
|
||||||
"Good": 2, "2": 2, 2: 2,
|
|
||||||
"Requires improvement": 3, "3": 3, 3: 3,
|
|
||||||
"Requires Improvement": 3,
|
|
||||||
"Inadequate": 4, "4": 4, 4: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Report Card grade text → integer (1=Exceptional … 5=Urgent improvement)
|
|
||||||
RC_GRADE_MAP = {
|
|
||||||
"exceptional": 1,
|
|
||||||
"strong standard": 2,
|
|
||||||
"strong": 2,
|
|
||||||
"expected standard": 3,
|
|
||||||
"expected": 3,
|
|
||||||
"needs attention": 4,
|
|
||||||
"urgent improvement": 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Column name priority for Report Card fields (best-guess names; Ofsted may vary)
|
|
||||||
RC_COLUMN_PRIORITY = {
|
|
||||||
"rc_safeguarding": [
|
|
||||||
"Safeguarding",
|
|
||||||
"safeguarding",
|
|
||||||
"Safeguarding standards",
|
|
||||||
],
|
|
||||||
"rc_inclusion": [
|
|
||||||
"Inclusion",
|
|
||||||
"inclusion",
|
|
||||||
],
|
|
||||||
"rc_curriculum_teaching": [
|
|
||||||
"Curriculum and teaching",
|
|
||||||
"curriculum_and_teaching",
|
|
||||||
"Curriculum & teaching",
|
|
||||||
],
|
|
||||||
"rc_achievement": [
|
|
||||||
"Achievement",
|
|
||||||
"achievement",
|
|
||||||
],
|
|
||||||
"rc_attendance_behaviour": [
|
|
||||||
"Attendance and behaviour",
|
|
||||||
"attendance_and_behaviour",
|
|
||||||
"Attendance & behaviour",
|
|
||||||
],
|
|
||||||
"rc_personal_development": [
|
|
||||||
"Personal development and well-being",
|
|
||||||
"Personal development and wellbeing",
|
|
||||||
"personal_development_and_wellbeing",
|
|
||||||
"Personal development & well-being",
|
|
||||||
],
|
|
||||||
"rc_leadership_governance": [
|
|
||||||
"Leadership and governance",
|
|
||||||
"leadership_and_governance",
|
|
||||||
"Leadership & governance",
|
|
||||||
],
|
|
||||||
"rc_early_years": [
|
|
||||||
"Early years",
|
|
||||||
"early_years",
|
|
||||||
"Early years provision",
|
|
||||||
],
|
|
||||||
"rc_sixth_form": [
|
|
||||||
"Sixth form",
|
|
||||||
"sixth_form",
|
|
||||||
"Sixth form in schools",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "ofsted"
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_csv_url() -> str | None:
|
|
||||||
"""Scrape the GOV.UK page for the most recent CSV/ZIP link."""
|
|
||||||
try:
|
|
||||||
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
|
||||||
resp.raise_for_status()
|
|
||||||
# Look for links to assets.publishing.service.gov.uk CSV or ZIP files
|
|
||||||
pattern = r'href="(https://assets\.publishing\.service\.gov\.uk[^"]+\.(?:csv|zip))"'
|
|
||||||
urls = re.findall(pattern, resp.text, re.IGNORECASE)
|
|
||||||
if urls:
|
|
||||||
return urls[0]
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: could not scrape GOV.UK page: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
url = _discover_csv_url()
|
|
||||||
if not url:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Could not discover Ofsted MI download URL. "
|
|
||||||
"Visit https://www.gov.uk/government/statistical-data-sets/"
|
|
||||||
"monthly-management-information-ofsteds-school-inspections-outcomes "
|
|
||||||
"to get the latest URL and update MANUAL_URL in ofsted.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
filename = url.split("/")[-1]
|
|
||||||
dest_file = dest / filename
|
|
||||||
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" Ofsted: {filename} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
print(f" Ofsted: downloading {url} ...")
|
|
||||||
resp = requests.get(url, timeout=120, stream=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
with open(dest_file, "wb") as f:
|
|
||||||
for chunk in resp.iter_content(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
print(f" Ofsted: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_grade(val) -> int | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
key = str(val).strip()
|
|
||||||
return GRADE_MAP.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_rc_grade(val) -> int | None:
|
|
||||||
"""Parse a Report Card grade text to integer 1–5."""
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
key = str(val).strip().lower()
|
|
||||||
return RC_GRADE_MAP.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_safeguarding(val) -> bool | None:
|
|
||||||
"""Parse safeguarding 'Met'/'Not met' to boolean."""
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().lower()
|
|
||||||
if s == "met":
|
|
||||||
return True
|
|
||||||
if s in ("not met", "not_met"):
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(val) -> date | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d %B %Y"):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(str(val).strip(), fmt).date()
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _framework_for_row(row) -> str | None:
|
|
||||||
"""Determine inspection framework for a single school row.
|
|
||||||
|
|
||||||
Check RC columns first — if any have a value, it's a Report Card inspection.
|
|
||||||
Fall back to OEIF columns. If neither has data, the school has no graded
|
|
||||||
inspection on record (return None).
|
|
||||||
"""
|
|
||||||
rc_check_cols = [
|
|
||||||
"rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
|
|
||||||
"rc_attendance_behaviour", "rc_personal_development",
|
|
||||||
"rc_leadership_governance", "rc_safeguarding",
|
|
||||||
]
|
|
||||||
for col in rc_check_cols:
|
|
||||||
val = row.get(col)
|
|
||||||
if val is not None and not (isinstance(val, float) and pd.isna(val)):
|
|
||||||
return "ReportCard"
|
|
||||||
|
|
||||||
oeif_check_cols = ["overall_effectiveness", "quality_of_education"]
|
|
||||||
for col in oeif_check_cols:
|
|
||||||
val = row.get(col)
|
|
||||||
if val is not None and not (isinstance(val, float) and pd.isna(val)):
|
|
||||||
return "OEIF"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.csv")) + sorted(dest.glob("*.zip"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No Ofsted MI file found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" Ofsted: loading {path} ...")
|
|
||||||
|
|
||||||
def _find_header_row(filepath, encoding="latin-1"):
|
|
||||||
"""Scan up to 10 rows to find the one containing a URN column."""
|
|
||||||
for i in range(10):
|
|
||||||
peek = pd.read_csv(filepath, encoding=encoding, header=i, nrows=0)
|
|
||||||
if any(str(c).strip() in ("URN", "Urn", "urn") for c in peek.columns):
|
|
||||||
return i
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if str(path).endswith(".zip"):
|
|
||||||
import zipfile, io
|
|
||||||
with zipfile.ZipFile(path) as z:
|
|
||||||
csv_names = [n for n in z.namelist() if n.endswith(".csv")]
|
|
||||||
if not csv_names:
|
|
||||||
raise ValueError("No CSV found inside Ofsted ZIP")
|
|
||||||
# Extract to a temp file so we can scan for the header row
|
|
||||||
import tempfile, os
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
|
|
||||||
tmp.write(z.read(csv_names[0]))
|
|
||||||
tmp_path = tmp.name
|
|
||||||
try:
|
|
||||||
hdr = _find_header_row(tmp_path)
|
|
||||||
df = pd.read_csv(tmp_path, encoding="latin-1", low_memory=False, header=hdr)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
else:
|
|
||||||
hdr = _find_header_row(path)
|
|
||||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False, header=hdr)
|
|
||||||
|
|
||||||
# Normalise OEIF column names: for each target field pick the first source column present
|
|
||||||
available = set(df.columns)
|
|
||||||
for target, sources in COLUMN_PRIORITY.items():
|
|
||||||
for src in sources:
|
|
||||||
if src in available:
|
|
||||||
df.rename(columns={src: target}, inplace=True)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Normalise Report Card column names (if present)
|
|
||||||
available = set(df.columns)
|
|
||||||
for target, sources in RC_COLUMN_PRIORITY.items():
|
|
||||||
for src in sources:
|
|
||||||
if src in available:
|
|
||||||
df.rename(columns={src: target}, inplace=True)
|
|
||||||
break
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
# Only keep rows with a valid URN
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
inserted = updated = skipped = 0
|
|
||||||
|
|
||||||
with get_session() as session:
|
|
||||||
# Keep only the most recent inspection per URN
|
|
||||||
if "inspection_date" in df.columns:
|
|
||||||
df["_date_parsed"] = df["inspection_date"].apply(_parse_date)
|
|
||||||
df = df.sort_values("_date_parsed", ascending=False).groupby("urn").first().reset_index()
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
|
|
||||||
record = {
|
|
||||||
"urn": urn,
|
|
||||||
"framework": _framework_for_row(row),
|
|
||||||
"inspection_date": _parse_date(row.get("inspection_date")),
|
|
||||||
"publication_date": _parse_date(row.get("publication_date")),
|
|
||||||
"inspection_type": str(row.get("inspection_type", "")).strip() or None,
|
|
||||||
# OEIF fields
|
|
||||||
"overall_effectiveness": _parse_grade(row.get("overall_effectiveness")),
|
|
||||||
"quality_of_education": _parse_grade(row.get("quality_of_education")),
|
|
||||||
"behaviour_attitudes": _parse_grade(row.get("behaviour_attitudes")),
|
|
||||||
"personal_development": _parse_grade(row.get("personal_development")),
|
|
||||||
"leadership_management": _parse_grade(row.get("leadership_management")),
|
|
||||||
"early_years_provision": _parse_grade(row.get("early_years_provision")),
|
|
||||||
"previous_overall": None,
|
|
||||||
# Report Card fields
|
|
||||||
"rc_safeguarding_met": _parse_safeguarding(row.get("rc_safeguarding")),
|
|
||||||
"rc_inclusion": _parse_rc_grade(row.get("rc_inclusion")),
|
|
||||||
"rc_curriculum_teaching": _parse_rc_grade(row.get("rc_curriculum_teaching")),
|
|
||||||
"rc_achievement": _parse_rc_grade(row.get("rc_achievement")),
|
|
||||||
"rc_attendance_behaviour": _parse_rc_grade(row.get("rc_attendance_behaviour")),
|
|
||||||
"rc_personal_development": _parse_rc_grade(row.get("rc_personal_development")),
|
|
||||||
"rc_leadership_governance": _parse_rc_grade(row.get("rc_leadership_governance")),
|
|
||||||
"rc_early_years": _parse_rc_grade(row.get("rc_early_years")),
|
|
||||||
"rc_sixth_form": _parse_rc_grade(row.get("rc_sixth_form")),
|
|
||||||
}
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO ofsted_inspections
|
|
||||||
(urn, framework, inspection_date, publication_date, inspection_type,
|
|
||||||
overall_effectiveness, quality_of_education, behaviour_attitudes,
|
|
||||||
personal_development, leadership_management, early_years_provision,
|
|
||||||
previous_overall,
|
|
||||||
rc_safeguarding_met, rc_inclusion, rc_curriculum_teaching,
|
|
||||||
rc_achievement, rc_attendance_behaviour, rc_personal_development,
|
|
||||||
rc_leadership_governance, rc_early_years, rc_sixth_form)
|
|
||||||
VALUES
|
|
||||||
(:urn, :framework, :inspection_date, :publication_date, :inspection_type,
|
|
||||||
:overall_effectiveness, :quality_of_education, :behaviour_attitudes,
|
|
||||||
:personal_development, :leadership_management, :early_years_provision,
|
|
||||||
:previous_overall,
|
|
||||||
:rc_safeguarding_met, :rc_inclusion, :rc_curriculum_teaching,
|
|
||||||
:rc_achievement, :rc_attendance_behaviour, :rc_personal_development,
|
|
||||||
:rc_leadership_governance, :rc_early_years, :rc_sixth_form)
|
|
||||||
ON CONFLICT (urn) DO UPDATE SET
|
|
||||||
previous_overall = ofsted_inspections.overall_effectiveness,
|
|
||||||
framework = EXCLUDED.framework,
|
|
||||||
inspection_date = EXCLUDED.inspection_date,
|
|
||||||
publication_date = EXCLUDED.publication_date,
|
|
||||||
inspection_type = EXCLUDED.inspection_type,
|
|
||||||
overall_effectiveness = EXCLUDED.overall_effectiveness,
|
|
||||||
quality_of_education = EXCLUDED.quality_of_education,
|
|
||||||
behaviour_attitudes = EXCLUDED.behaviour_attitudes,
|
|
||||||
personal_development = EXCLUDED.personal_development,
|
|
||||||
leadership_management = EXCLUDED.leadership_management,
|
|
||||||
early_years_provision = EXCLUDED.early_years_provision,
|
|
||||||
rc_safeguarding_met = EXCLUDED.rc_safeguarding_met,
|
|
||||||
rc_inclusion = EXCLUDED.rc_inclusion,
|
|
||||||
rc_curriculum_teaching = EXCLUDED.rc_curriculum_teaching,
|
|
||||||
rc_achievement = EXCLUDED.rc_achievement,
|
|
||||||
rc_attendance_behaviour = EXCLUDED.rc_attendance_behaviour,
|
|
||||||
rc_personal_development = EXCLUDED.rc_personal_development,
|
|
||||||
rc_leadership_governance = EXCLUDED.rc_leadership_governance,
|
|
||||||
rc_early_years = EXCLUDED.rc_early_years,
|
|
||||||
rc_sixth_form = EXCLUDED.rc_sixth_form
|
|
||||||
"""),
|
|
||||||
record,
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
if inserted % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
print(f" Processed {inserted} records...")
|
|
||||||
|
|
||||||
print(f" Ofsted: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": updated, "skipped": skipped}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
path = download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"""
|
|
||||||
Ofsted Parent View open data downloader and loader.
|
|
||||||
|
|
||||||
Source: https://parentview.ofsted.gov.uk/open-data
|
|
||||||
Update: ~3 times/year (Spring, Autumn, Summer)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import date, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "parent_view"
|
|
||||||
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
|
|
||||||
|
|
||||||
# Question column mapping — Parent View open data uses descriptive column headers
|
|
||||||
# Map any variant to our internal field names
|
|
||||||
QUESTION_MAP = {
|
|
||||||
# Q1 — happiness
|
|
||||||
"My child is happy at this school": "q_happy_pct",
|
|
||||||
"Happy": "q_happy_pct",
|
|
||||||
# Q2 — safety
|
|
||||||
"My child feels safe at this school": "q_safe_pct",
|
|
||||||
"Safe": "q_safe_pct",
|
|
||||||
# Q3 — bullying
|
|
||||||
"The school makes sure its pupils are well behaved": "q_behaviour_pct",
|
|
||||||
"Well Behaved": "q_behaviour_pct",
|
|
||||||
# Q4 — bullying dealt with (sometimes separate)
|
|
||||||
"My child has been bullied and the school dealt with the bullying quickly and effectively": "q_bullying_pct",
|
|
||||||
"Bullying": "q_bullying_pct",
|
|
||||||
# Q5 — curriculum info
|
|
||||||
"The school makes me aware of what my child will learn during the year": "q_communication_pct",
|
|
||||||
"Aware of learning": "q_communication_pct",
|
|
||||||
# Q6 — concerns dealt with
|
|
||||||
"When I have raised concerns with the school, they have been dealt with properly": "q_communication_pct",
|
|
||||||
# Q7 — child does well
|
|
||||||
"My child does well at this school": "q_progress_pct",
|
|
||||||
"Does well": "q_progress_pct",
|
|
||||||
# Q8 — teaching
|
|
||||||
"The teaching is good at this school": "q_teaching_pct",
|
|
||||||
"Good teaching": "q_teaching_pct",
|
|
||||||
# Q9 — progress info
|
|
||||||
"I receive valuable information from the school about my child's progress": "q_information_pct",
|
|
||||||
"Progress information": "q_information_pct",
|
|
||||||
# Q10 — curriculum breadth
|
|
||||||
"My child is taught a broad range of subjects": "q_curriculum_pct",
|
|
||||||
"Broad subjects": "q_curriculum_pct",
|
|
||||||
# Q11 — prepares for future
|
|
||||||
"The school prepares my child well for the future": "q_future_pct",
|
|
||||||
"Prepared for future": "q_future_pct",
|
|
||||||
# Q12 — leadership
|
|
||||||
"The school is led and managed effectively": "q_leadership_pct",
|
|
||||||
"Led well": "q_leadership_pct",
|
|
||||||
# Q13 — wellbeing
|
|
||||||
"The school supports my child's wider personal development": "q_wellbeing_pct",
|
|
||||||
"Personal development": "q_wellbeing_pct",
|
|
||||||
# Q14 — recommendation
|
|
||||||
"I would recommend this school to another parent": "q_recommend_pct",
|
|
||||||
"Recommend": "q_recommend_pct",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Scrape the open data page for the download link
|
|
||||||
try:
|
|
||||||
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
|
|
||||||
resp.raise_for_status()
|
|
||||||
pattern = r'href="([^"]+\.(?:xlsx|csv|zip))"'
|
|
||||||
urls = re.findall(pattern, resp.text, re.IGNORECASE)
|
|
||||||
if not urls:
|
|
||||||
raise RuntimeError("No download link found on Parent View open data page")
|
|
||||||
url = urls[0] if urls[0].startswith("http") else "https://parentview.ofsted.gov.uk" + urls[0]
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Could not discover Parent View download URL: {e}")
|
|
||||||
|
|
||||||
filename = url.split("/")[-1].split("?")[0]
|
|
||||||
dest_file = dest / filename
|
|
||||||
|
|
||||||
if dest_file.exists():
|
|
||||||
print(f" ParentView: {filename} already exists, skipping download.")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
print(f" ParentView: downloading {url} ...")
|
|
||||||
resp = requests.get(url, timeout=120, stream=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
with open(dest_file, "wb") as f:
|
|
||||||
for chunk in resp.iter_content(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
print(f" ParentView: saved {dest_file}")
|
|
||||||
return dest_file
|
|
||||||
|
|
||||||
|
|
||||||
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
|
|
||||||
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
|
|
||||||
# Parent View open data has columns like "Q1 - Strongly agree %", "Q1 - Agree %"
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.xlsx")) + sorted(dest.glob("*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No Parent View file found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" ParentView: loading {path} ...")
|
|
||||||
|
|
||||||
if str(path).endswith(".xlsx"):
|
|
||||||
df = pd.read_excel(path)
|
|
||||||
else:
|
|
||||||
df = pd.read_csv(path, 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:
|
|
||||||
raise ValueError(f"URN column not found. Columns: {list(df.columns)[:20]}")
|
|
||||||
df.rename(columns={urn_col: "urn"}, inplace=True)
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
# Try to find total responses column
|
|
||||||
resp_col = next((c for c in df.columns if "total" in c.lower() and "respon" in c.lower()), None)
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
today = date.today()
|
|
||||||
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
total = int(row[resp_col]) if resp_col and pd.notna(row.get(resp_col)) else None
|
|
||||||
|
|
||||||
# Try to extract % positive per question from wide-format columns
|
|
||||||
# Parent View has numbered questions Q1–Q12 (or Q1–Q14 depending on year)
|
|
||||||
record = {
|
|
||||||
"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"),
|
|
||||||
"q_sen_pct": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO ofsted_parent_view
|
|
||||||
(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, q_sen_pct)
|
|
||||||
VALUES
|
|
||||||
(: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, :q_sen_pct)
|
|
||||||
ON CONFLICT (urn) DO UPDATE SET
|
|
||||||
survey_date = EXCLUDED.survey_date,
|
|
||||||
total_responses = EXCLUDED.total_responses,
|
|
||||||
q_happy_pct = EXCLUDED.q_happy_pct,
|
|
||||||
q_safe_pct = EXCLUDED.q_safe_pct,
|
|
||||||
q_behaviour_pct = EXCLUDED.q_behaviour_pct,
|
|
||||||
q_bullying_pct = EXCLUDED.q_bullying_pct,
|
|
||||||
q_communication_pct = EXCLUDED.q_communication_pct,
|
|
||||||
q_progress_pct = EXCLUDED.q_progress_pct,
|
|
||||||
q_teaching_pct = EXCLUDED.q_teaching_pct,
|
|
||||||
q_information_pct = EXCLUDED.q_information_pct,
|
|
||||||
q_curriculum_pct = EXCLUDED.q_curriculum_pct,
|
|
||||||
q_future_pct = EXCLUDED.q_future_pct,
|
|
||||||
q_leadership_pct = EXCLUDED.q_leadership_pct,
|
|
||||||
q_wellbeing_pct = EXCLUDED.q_wellbeing_pct,
|
|
||||||
q_recommend_pct = EXCLUDED.q_recommend_pct,
|
|
||||||
q_sen_pct = EXCLUDED.q_sen_pct
|
|
||||||
"""),
|
|
||||||
record,
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 2000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" ParentView: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"""
|
|
||||||
Phonics Screening Check downloader and loader.
|
|
||||||
|
|
||||||
Source: EES publication "phonics-screening-check-and-key-stage-1-assessments-england"
|
|
||||||
Update: Annual (September/October)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
from sources.ees import get_latest_csv_url, download_csv
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "phonics"
|
|
||||||
PUBLICATION_SLUG = "phonics-screening-check-and-key-stage-1-assessments-england"
|
|
||||||
|
|
||||||
# Known column names in the phonics CSV (vary by year)
|
|
||||||
COLUMN_MAP = {
|
|
||||||
"URN": "urn",
|
|
||||||
"urn": "urn",
|
|
||||||
# Year 1 pass rate
|
|
||||||
"PPTA1": "year1_phonics_pct", # % meeting expected standard Y1
|
|
||||||
"PPTA1B": "year1_phonics_pct",
|
|
||||||
"PT_MET_PHON_Y1": "year1_phonics_pct",
|
|
||||||
"Y1_MET_EXPECTED_PCT": "year1_phonics_pct",
|
|
||||||
# Year 2 (re-takers)
|
|
||||||
"PPTA2": "year2_phonics_pct",
|
|
||||||
"PT_MET_PHON_Y2": "year2_phonics_pct",
|
|
||||||
"Y2_MET_EXPECTED_PCT": "year2_phonics_pct",
|
|
||||||
# Year label
|
|
||||||
"YEAR": "year",
|
|
||||||
"Year": "year",
|
|
||||||
}
|
|
||||||
|
|
||||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", ""}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
|
||||||
if not url:
|
|
||||||
raise RuntimeError(f"Could not find CSV URL for phonics publication")
|
|
||||||
|
|
||||||
filename = url.split("/")[-1].split("?")[0] or "phonics_latest.csv"
|
|
||||||
return download_csv(url, dest / filename)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pct(val) -> float | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().upper().replace("%", "")
|
|
||||||
if s in NULL_VALUES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No phonics CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" Phonics: loading {path} ...")
|
|
||||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
|
||||||
df.rename(columns=COLUMN_MAP, inplace=True)
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
# Infer year from filename if not in data
|
|
||||||
year = None
|
|
||||||
import re
|
|
||||||
m = re.search(r"20(\d{2})", path.stem)
|
|
||||||
if m:
|
|
||||||
year = int("20" + m.group(1))
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
|
|
||||||
if not row_year:
|
|
||||||
continue
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO phonics (urn, year, year1_phonics_pct, year2_phonics_pct)
|
|
||||||
VALUES (:urn, :year, :y1, :y2)
|
|
||||||
ON CONFLICT (urn, year) DO UPDATE SET
|
|
||||||
year1_phonics_pct = EXCLUDED.year1_phonics_pct,
|
|
||||||
year2_phonics_pct = EXCLUDED.year2_phonics_pct
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": urn,
|
|
||||||
"year": row_year,
|
|
||||||
"y1": _parse_pct(row.get("year1_phonics_pct")),
|
|
||||||
"y2": _parse_pct(row.get("year2_phonics_pct")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" Phonics: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""
|
|
||||||
SEN (Special Educational Needs) primary need type breakdown.
|
|
||||||
|
|
||||||
Source: EES publication "special-educational-needs-in-england"
|
|
||||||
Update: Annual (September)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from config import SUPPLEMENTARY_DIR
|
|
||||||
from db import get_session
|
|
||||||
from sources.ees import get_latest_csv_url, download_csv
|
|
||||||
|
|
||||||
DEST_DIR = SUPPLEMENTARY_DIR / "sen_detail"
|
|
||||||
PUBLICATION_SLUG = "special-educational-needs-in-england"
|
|
||||||
|
|
||||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
|
|
||||||
|
|
||||||
COLUMN_MAP = {
|
|
||||||
"URN": "urn",
|
|
||||||
"urn": "urn",
|
|
||||||
"YEAR": "year",
|
|
||||||
"Year": "year",
|
|
||||||
# Primary need types — DfE abbreviated codes
|
|
||||||
"PT_SPEECH": "primary_need_speech_pct", # SLCN
|
|
||||||
"PT_ASD": "primary_need_autism_pct", # ASD
|
|
||||||
"PT_MLD": "primary_need_mld_pct", # Moderate learning difficulty
|
|
||||||
"PT_SPLD": "primary_need_spld_pct", # Specific learning difficulty
|
|
||||||
"PT_SEMH": "primary_need_semh_pct", # Social, emotional, mental health
|
|
||||||
"PT_PHYSICAL": "primary_need_physical_pct", # Physical/sensory
|
|
||||||
"PT_OTHER": "primary_need_other_pct",
|
|
||||||
# Alternative naming
|
|
||||||
"SLCN_PCT": "primary_need_speech_pct",
|
|
||||||
"ASD_PCT": "primary_need_autism_pct",
|
|
||||||
"MLD_PCT": "primary_need_mld_pct",
|
|
||||||
"SPLD_PCT": "primary_need_spld_pct",
|
|
||||||
"SEMH_PCT": "primary_need_semh_pct",
|
|
||||||
"PHYSICAL_PCT": "primary_need_physical_pct",
|
|
||||||
"OTHER_PCT": "primary_need_other_pct",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download(data_dir: Path | None = None) -> Path:
|
|
||||||
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
|
||||||
if not url:
|
|
||||||
url = get_latest_csv_url(PUBLICATION_SLUG)
|
|
||||||
if not url:
|
|
||||||
raise RuntimeError("Could not find CSV URL for SEN publication")
|
|
||||||
|
|
||||||
filename = url.split("/")[-1].split("?")[0] or "sen_latest.csv"
|
|
||||||
return download_csv(url, dest / filename)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pct(val) -> float | None:
|
|
||||||
if pd.isna(val):
|
|
||||||
return None
|
|
||||||
s = str(val).strip().upper().replace("%", "")
|
|
||||||
if s in NULL_VALUES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
|
||||||
if path is None:
|
|
||||||
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
|
|
||||||
files = sorted(dest.glob("*.csv"))
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No SEN CSV found in {dest}")
|
|
||||||
path = files[-1]
|
|
||||||
|
|
||||||
print(f" SEN Detail: loading {path} ...")
|
|
||||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
|
||||||
df.rename(columns=COLUMN_MAP, inplace=True)
|
|
||||||
|
|
||||||
if "urn" not in df.columns:
|
|
||||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
|
||||||
|
|
||||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
|
||||||
df = df.dropna(subset=["urn"])
|
|
||||||
df["urn"] = df["urn"].astype(int)
|
|
||||||
|
|
||||||
year = None
|
|
||||||
m = re.search(r"20(\d{2})", path.stem)
|
|
||||||
if m:
|
|
||||||
year = int("20" + m.group(1))
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
with get_session() as session:
|
|
||||||
from sqlalchemy import text
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
|
|
||||||
if not row_year:
|
|
||||||
continue
|
|
||||||
|
|
||||||
session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO sen_detail
|
|
||||||
(urn, year, primary_need_speech_pct, primary_need_autism_pct,
|
|
||||||
primary_need_mld_pct, primary_need_spld_pct, primary_need_semh_pct,
|
|
||||||
primary_need_physical_pct, primary_need_other_pct)
|
|
||||||
VALUES (:urn, :year, :speech, :autism, :mld, :spld, :semh, :physical, :other)
|
|
||||||
ON CONFLICT (urn, year) DO UPDATE SET
|
|
||||||
primary_need_speech_pct = EXCLUDED.primary_need_speech_pct,
|
|
||||||
primary_need_autism_pct = EXCLUDED.primary_need_autism_pct,
|
|
||||||
primary_need_mld_pct = EXCLUDED.primary_need_mld_pct,
|
|
||||||
primary_need_spld_pct = EXCLUDED.primary_need_spld_pct,
|
|
||||||
primary_need_semh_pct = EXCLUDED.primary_need_semh_pct,
|
|
||||||
primary_need_physical_pct = EXCLUDED.primary_need_physical_pct,
|
|
||||||
primary_need_other_pct = EXCLUDED.primary_need_other_pct
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"urn": urn, "year": row_year,
|
|
||||||
"speech": _parse_pct(row.get("primary_need_speech_pct")),
|
|
||||||
"autism": _parse_pct(row.get("primary_need_autism_pct")),
|
|
||||||
"mld": _parse_pct(row.get("primary_need_mld_pct")),
|
|
||||||
"spld": _parse_pct(row.get("primary_need_spld_pct")),
|
|
||||||
"semh": _parse_pct(row.get("primary_need_semh_pct")),
|
|
||||||
"physical": _parse_pct(row.get("primary_need_physical_pct")),
|
|
||||||
"other": _parse_pct(row.get("primary_need_other_pct")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
if inserted % 5000 == 0:
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
print(f" SEN Detail: upserted {inserted} records")
|
|
||||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
|
||||||
parser.add_argument("--data-dir", type=Path, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action in ("download", "all"):
|
|
||||||
download(args.data_dir)
|
|
||||||
if args.action in ("load", "all"):
|
|
||||||
load(data_dir=args.data_dir)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""
|
|
||||||
Data integrator HTTP server.
|
|
||||||
Kestra calls this server via HTTP tasks to trigger download/load operations.
|
|
||||||
"""
|
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
sys.path.insert(0, "/app/scripts")
|
|
||||||
|
|
||||||
app = FastAPI(title="SchoolCompare Data Integrator", version="1.0.0")
|
|
||||||
|
|
||||||
SOURCES = {
|
|
||||||
"ofsted", "gias", "parent_view",
|
|
||||||
"census", "admissions", "sen_detail",
|
|
||||||
"phonics", "idaci", "finance", "ks2",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/run/{source}")
|
|
||||||
def run_source(source: str, action: str = "all"):
|
|
||||||
"""
|
|
||||||
Trigger a data source download and/or load.
|
|
||||||
action: "download" | "load" | "all"
|
|
||||||
"""
|
|
||||||
if source not in SOURCES:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown source '{source}'. Available: {sorted(SOURCES)}")
|
|
||||||
if action not in ("download", "load", "all"):
|
|
||||||
raise HTTPException(status_code=400, detail="action must be 'download', 'load', or 'all'")
|
|
||||||
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module(f"sources.{source}")
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
if action in ("download", "all"):
|
|
||||||
mod.download()
|
|
||||||
|
|
||||||
if action in ("load", "all"):
|
|
||||||
result = mod.load()
|
|
||||||
|
|
||||||
return {"source": source, "action": action, "result": result}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
tb = traceback.format_exc()
|
|
||||||
raise HTTPException(status_code=500, detail={"error": str(e), "traceback": tb})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/run-all")
|
|
||||||
def run_all(action: str = "all"):
|
|
||||||
"""Trigger all sources in sequence."""
|
|
||||||
results = {}
|
|
||||||
for source in sorted(SOURCES):
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module(f"sources.{source}")
|
|
||||||
if action in ("download", "all"):
|
|
||||||
mod.download()
|
|
||||||
if action in ("load", "all"):
|
|
||||||
results[source] = mod.load()
|
|
||||||
except Exception as e:
|
|
||||||
results[source] = {"error": str(e)}
|
|
||||||
return results
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Admissions trend — Chart.js render check</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg-primary:#faf7f2;--bg-secondary:#f3ede4;--bg-card:#fff;
|
||||||
|
--text-primary:#1a1612;--text-secondary:#5c564d;--text-muted:#6d685f;
|
||||||
|
--accent-coral:#e07256;--border:#e5dfd5;--shadow:0 2px 8px rgba(26,22,18,.06);
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg-primary);color:var(--text-primary);padding:40px 20px}
|
||||||
|
.wrap{max-width:900px;margin:0 auto}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);padding:28px}
|
||||||
|
.head{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem}
|
||||||
|
.title{font-family:'Playfair Display',serif;font-weight:600;font-size:22px}
|
||||||
|
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px}
|
||||||
|
.seg button{border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;color:var(--text-muted);padding:6px 14px;border-radius:999px}
|
||||||
|
.seg button[aria-pressed="true"]{background:#fff;color:var(--text-primary);box-shadow:var(--shadow)}
|
||||||
|
.cap{font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:.5rem}
|
||||||
|
.chartWrap{width:100%;height:200px}
|
||||||
|
.summary{font-size:16px;color:var(--text-secondary);margin-top:1.25rem;padding-top:1.1rem;border-top:1px solid var(--border)}
|
||||||
|
.summary strong{color:var(--text-primary)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<div class="head">
|
||||||
|
<div class="title">How Hard to Get Into This School</div>
|
||||||
|
<div class="seg"><button aria-pressed="false">This year</button><button aria-pressed="true">13-year trend</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="cap">First-choice offer rate</div>
|
||||||
|
<div class="chartWrap"><canvas id="c"></canvas></div>
|
||||||
|
<p class="summary">This year (2026/27), <strong>30</strong> families put it first for <strong>30</strong> places — 101 applications in total.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const labels = ['2014/15','2015/16','2016/17','2017/18','2018/19','2019/20','2020/21','2021/22','2022/23','2023/24','2024/25','2025/26','2026/27'];
|
||||||
|
const values = [64,74,63,59,45,69,66,93,100,100,90,64,80];
|
||||||
|
const lastIdx = values.length-1;
|
||||||
|
const lo=Math.min(...values),hi=Math.max(...values);
|
||||||
|
const padded=Math.max(5,Math.round((hi-lo)*0.25));
|
||||||
|
const yMin=Math.max(0,Math.floor((lo-padded)/5)*5);
|
||||||
|
const yMax=Math.min(100,Math.ceil((hi+padded)/5)*5);
|
||||||
|
new Chart(document.getElementById('c'),{
|
||||||
|
type:'line',
|
||||||
|
data:{labels,datasets:[{
|
||||||
|
label:'First-choice offer rate',data:values,clip:false,
|
||||||
|
borderColor:'#e07256',backgroundColor:'rgba(224,114,86,0.10)',
|
||||||
|
borderWidth:2.5,tension:0.3,fill:true,
|
||||||
|
pointRadius:values.map((_,i)=>i===lastIdx?5:3),
|
||||||
|
pointBackgroundColor:'#e07256',pointBorderColor:'#fff',
|
||||||
|
pointBorderWidth:values.map((_,i)=>i===lastIdx?2:0),pointHoverRadius:6,
|
||||||
|
}]},
|
||||||
|
options:{
|
||||||
|
responsive:true,maintainAspectRatio:false,
|
||||||
|
layout:{padding:{top:8}},
|
||||||
|
interaction:{mode:'index',intersect:false},
|
||||||
|
plugins:{legend:{display:false},title:{display:false},
|
||||||
|
tooltip:{backgroundColor:'rgba(26,22,18,0.92)',padding:10,callbacks:{label:ctx=>`First-choice offers: ${Math.round(ctx.parsed.y)}%`}}},
|
||||||
|
scales:{
|
||||||
|
y:{min:yMin,max:yMax,grid:{color:'rgba(0,0,0,0.05)'},ticks:{font:{size:11},maxTicksLimit:5,callback:v=>`${v}%`}},
|
||||||
|
x:{grid:{display:false},ticks:{font:{size:11},autoSkip:true,maxRotation:0,autoSkipPadding:16}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Multi-year admissions — mockups</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
||||||
|
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
||||||
|
--accent-coral:#e07256; --accent-coral-dark:#c45a3f; --accent-teal:#2d7d7d;
|
||||||
|
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
|
||||||
|
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
|
||||||
|
--trend-up:#16a34a; --trend-down:#e07256;
|
||||||
|
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
|
||||||
|
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
|
||||||
|
--serif:'Playfair Display',Georgia,serif; --sans:'DM Sans',-apple-system,sans-serif;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;padding:48px 20px 96px}
|
||||||
|
.wrap{max-width:760px;margin:0 auto}
|
||||||
|
.pagehead{margin-bottom:40px}
|
||||||
|
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:32px;letter-spacing:-.01em}
|
||||||
|
.pagehead p{color:var(--text-secondary);margin-top:8px;font-size:15px}
|
||||||
|
.optlabel{display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent-teal);margin:56px 0 6px}
|
||||||
|
.optlabel .rec{background:var(--teal-bg);color:var(--accent-teal);padding:2px 8px;border-radius:999px;letter-spacing:.02em;text-transform:none;font-weight:600}
|
||||||
|
.optdesc{color:var(--text-muted);font-size:14px;margin-bottom:16px;max-width:60ch}
|
||||||
|
|
||||||
|
/* card shell mimicking SchoolDetailView .card */
|
||||||
|
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow);padding:28px 28px 26px}
|
||||||
|
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em;margin-bottom:18px}
|
||||||
|
|
||||||
|
/* ===== verdict block ===== */
|
||||||
|
.verdict{display:flex;gap:14px;align-items:flex-start;padding:16px 18px;border-radius:var(--radius-md);margin-bottom:22px}
|
||||||
|
.verdict.hard{background:var(--coral-bg)}
|
||||||
|
.verdict.easing{background:var(--teal-bg)}
|
||||||
|
.verdict .vicon{font-size:20px;line-height:1.2}
|
||||||
|
.verdict .vhead{font-weight:700;font-size:17px;letter-spacing:-.01em}
|
||||||
|
.verdict.hard .vhead{color:var(--accent-coral-dark)}
|
||||||
|
.verdict.easing .vhead{color:var(--accent-teal)}
|
||||||
|
.verdict .vsub{color:var(--text-secondary);font-size:14px;margin-top:3px}
|
||||||
|
|
||||||
|
.trendtag{display:inline-flex;align-items:center;gap:5px;font-weight:600}
|
||||||
|
.trendtag.up{color:var(--trend-down)} /* harder = coral */
|
||||||
|
.trendtag.down{color:var(--trend-up)} /* easier = green */
|
||||||
|
|
||||||
|
/* ===== chart ===== */
|
||||||
|
.chartwrap{margin:6px 0 4px}
|
||||||
|
.chartcap{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px}
|
||||||
|
.chart{width:100%;height:auto;display:block}
|
||||||
|
.axisyr{font-size:11px;fill:var(--text-muted);font-family:var(--sans)}
|
||||||
|
.ptlbl{font-size:12px;font-weight:700;font-family:var(--sans)}
|
||||||
|
|
||||||
|
/* latest line */
|
||||||
|
.latest{margin-top:18px;padding-top:16px;border-top:1px solid var(--border);font-size:14px;color:var(--text-secondary)}
|
||||||
|
.latest b{color:var(--text-primary)}
|
||||||
|
|
||||||
|
/* ===== Q&A (existing style) ===== */
|
||||||
|
.qa{display:grid;gap:0}
|
||||||
|
.qa .row{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.qa .row:last-child{border-bottom:none}
|
||||||
|
.qa .q{color:var(--text-secondary);font-size:14px}
|
||||||
|
.qa .a{font-weight:700;font-size:16px;white-space:nowrap}
|
||||||
|
.qa .a .sub{font-weight:400;color:var(--text-muted);font-size:13px;margin-left:6px}
|
||||||
|
|
||||||
|
/* ===== table ===== */
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||||
|
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);padding:0 10px 10px;border-bottom:1px solid var(--border)}
|
||||||
|
thead th:first-child{text-align:left}
|
||||||
|
tbody td{text-align:right;padding:12px 10px;border-bottom:1px solid var(--border)}
|
||||||
|
tbody td:first-child{text-align:left;font-weight:600}
|
||||||
|
tbody tr:last-child td{border-bottom:none}
|
||||||
|
tbody tr.latest-row{background:var(--bg-secondary)}
|
||||||
|
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px}
|
||||||
|
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||||
|
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
|
||||||
|
.note{display:flex;gap:8px;margin-top:16px;font-size:13px;color:var(--text-muted)}
|
||||||
|
.note .i{flex:none;width:16px;height:16px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
|
||||||
|
|
||||||
|
/* ===== disclosure ===== */
|
||||||
|
.disclosure{margin-top:18px;border-top:1px solid var(--border);padding-top:6px}
|
||||||
|
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;font-weight:600;font-size:14px;color:var(--accent-teal)}
|
||||||
|
.disclosure>summary::-webkit-details-marker{display:none}
|
||||||
|
.disclosure>summary .chev{transition:transform .2s ease}
|
||||||
|
.disclosure[open]>summary .chev{transform:rotate(90deg)}
|
||||||
|
.disclosure .body{padding-top:8px}
|
||||||
|
|
||||||
|
/* hero chips preview */
|
||||||
|
.heroctx{margin:10px 0 14px;font-size:12px;color:var(--text-muted)}
|
||||||
|
.chips{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px}
|
||||||
|
.hchip{border-radius:var(--radius-md);padding:12px 16px;min-width:170px;background:var(--coral-bg)}
|
||||||
|
.hchip .t{font-weight:700;font-size:14px;color:var(--accent-coral-dark)}
|
||||||
|
.hchip .s{font-size:12px;color:var(--text-secondary);margin-top:2px}
|
||||||
|
|
||||||
|
/* ===== card header with segmented toggle ===== */
|
||||||
|
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
||||||
|
.cardhead .sectionTitle{margin-bottom:0}
|
||||||
|
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
|
||||||
|
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;color:var(--text-muted);
|
||||||
|
padding:6px 14px;border-radius:999px;transition:background .15s ease,color .15s ease;white-space:nowrap}
|
||||||
|
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
|
||||||
|
.seg button:hover[aria-pressed="false"]{color:var(--text-secondary)}
|
||||||
|
/* Stack both views in one grid cell so the card sizes to the taller view —
|
||||||
|
switching modes never shifts layout. */
|
||||||
|
.viewport{display:grid}
|
||||||
|
.viewport > .view{grid-area:1 / 1}
|
||||||
|
.viewport > .view[hidden]{display:block;visibility:hidden;pointer-events:none}
|
||||||
|
/* Default view fills the reserved height: rows spread to consume the slack
|
||||||
|
left by the taller trend view, so there's no empty gap below. */
|
||||||
|
#view-year{display:flex;flex-direction:column}
|
||||||
|
#view-year .qa{flex:1;display:flex;flex-direction:column;justify-content:space-between}
|
||||||
|
/* Larger type in the proposed card so content carries the height
|
||||||
|
instead of empty space between small rows. */
|
||||||
|
#proposed .qa .row{padding:16px 0}
|
||||||
|
#proposed .qa .q{font-size:16px}
|
||||||
|
#proposed .qa .a{font-size:20px}
|
||||||
|
#proposed .qa .a .sub{font-size:14px}
|
||||||
|
#proposed .chartcap{font-size:13px}
|
||||||
|
#proposed .latest{font-size:16px;margin-top:22px;padding-top:18px}
|
||||||
|
#proposed .axisyr{font-size:12.5px}
|
||||||
|
#proposed .ptlbl{font-size:13px}
|
||||||
|
|
||||||
|
@media(max-width:600px){
|
||||||
|
.card{padding:22px 18px}
|
||||||
|
.qa .a{font-size:15px}
|
||||||
|
thead th,tbody td{padding-left:6px;padding-right:6px}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<div class="pagehead">
|
||||||
|
<h1>How Hard to Get In — multi-year</h1>
|
||||||
|
<p>Three ways to surface admissions history on the school detail page. Sample data: an oversubscribed primary where first-choice odds have tightened from 95% → 68% over three years.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ PROPOSED: current view + header toggle to Option C ============ -->
|
||||||
|
<div class="optlabel">Proposed <span class="rec">Header toggle</span></div>
|
||||||
|
<p class="optdesc">Keeps today's single-year view as the default. A segmented toggle in the card header switches between "This year" and "3-year trend" (Option C). Try it — click the toggle.</p>
|
||||||
|
|
||||||
|
<section class="card" id="proposed">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
|
||||||
|
<div class="seg" role="group" aria-label="Admissions view">
|
||||||
|
<button data-show="year" aria-pressed="true" type="button">This year</button>
|
||||||
|
<button data-show="trend" aria-pressed="false" type="button">3-year trend</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewport">
|
||||||
|
<!-- DEFAULT: current single-year Q&A -->
|
||||||
|
<div class="view" id="view-year">
|
||||||
|
<div class="qa">
|
||||||
|
<div class="row"><span class="q">How many places were offered?</span><span class="a">60</span></div>
|
||||||
|
<div class="row"><span class="q">How many families wanted this school first?</span><span class="a">88</span></div>
|
||||||
|
<div class="row"><span class="q">How many got their first choice?</span><span class="a">60<span class="sub">of 88 (68%)</span></span></div>
|
||||||
|
<div class="row"><span class="q">How many applied in total?</span><span class="a">241</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SWAPPED: Option C trend view -->
|
||||||
|
<div class="view" id="view-trend" hidden>
|
||||||
|
<div class="chartwrap">
|
||||||
|
<div class="chartcap">First-choice offer rate</div>
|
||||||
|
<svg class="chart" viewBox="0 0 520 118" role="img" aria-label="First-choice offer rate falling from 95% in 2021/22 to 68% in 2023/24">
|
||||||
|
<line x1="44" y1="16" x2="500" y2="16" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<line x1="44" y1="50" x2="500" y2="50" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<line x1="44" y1="84" x2="500" y2="84" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<text x="36" y="20" text-anchor="end" class="axisyr">100%</text>
|
||||||
|
<text x="36" y="54" text-anchor="end" class="axisyr">75%</text>
|
||||||
|
<text x="36" y="88" text-anchor="end" class="axisyr">50%</text>
|
||||||
|
<polyline points="110,21 290,41 470,59" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="110" cy="21" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="290" cy="41" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="470" cy="59" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="110" y="13" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
|
||||||
|
<text x="290" y="33" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
|
||||||
|
<text x="470" y="51" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
|
||||||
|
<text x="110" y="106" text-anchor="middle" class="axisyr">2021/22</text>
|
||||||
|
<text x="290" y="106" text-anchor="middle" class="axisyr">2022/23</text>
|
||||||
|
<text x="470" y="106" text-anchor="middle" class="axisyr">2023/24</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="latest">This year (2023/24), <b>88 families</b> put it first for <b>60 places</b> — 241 applications in total.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="optdesc" style="margin-top:14px">Below: the standalone option mockups for reference.</p>
|
||||||
|
|
||||||
|
<!-- ============ OPTION C (RECOMMENDED) ============ -->
|
||||||
|
<div class="optlabel">Option C <span class="rec">Recommended</span></div>
|
||||||
|
<p class="optdesc">Trend verdict + sparkline, with the full year-by-year table behind a disclosure. Answers the parent's question first, rewards the curious second. Collapses to today's single-year view when only one year exists.</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
|
||||||
|
|
||||||
|
<div class="chartwrap">
|
||||||
|
<div class="chartcap">First-choice offer rate</div>
|
||||||
|
<svg class="chart" viewBox="0 0 520 170" role="img" aria-label="First-choice offer rate falling from 95% in 2021/22 to 68% in 2023/24">
|
||||||
|
<!-- gridlines -->
|
||||||
|
<line x1="40" y1="20" x2="500" y2="20" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="70" x2="500" y2="70" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="120" x2="500" y2="120" stroke="#e5dfd5" stroke-width="1"/>
|
||||||
|
<text x="32" y="24" text-anchor="end" class="axisyr">100%</text>
|
||||||
|
<text x="32" y="74" text-anchor="end" class="axisyr">75%</text>
|
||||||
|
<text x="32" y="124" text-anchor="end" class="axisyr">50%</text>
|
||||||
|
<!-- 95% -> y=30 ; 81% -> y=58 ; 68% -> y=84 (y = 20 + (100-v)*2) -->
|
||||||
|
<polyline points="110,30 290,58 470,84" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="110" cy="30" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="290" cy="58" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="470" cy="84" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="110" y="20" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
|
||||||
|
<text x="290" y="48" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
|
||||||
|
<text x="470" y="74" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
|
||||||
|
<text x="110" y="150" text-anchor="middle" class="axisyr">2021/22</text>
|
||||||
|
<text x="290" y="150" text-anchor="middle" class="axisyr">2022/23</text>
|
||||||
|
<text x="470" y="150" text-anchor="middle" class="axisyr">2023/24</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="latest">This year (2023/24), <b>88 families</b> put it first for <b>60 places</b> — 241 applications in total.</div>
|
||||||
|
|
||||||
|
<details class="disclosure">
|
||||||
|
<summary><span class="chev">▸</span> See full 3-year breakdown</summary>
|
||||||
|
<div class="body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Year</th><th>Places</th><th>1st-pref apps</th><th>1st-choice rate</th><th>Total apps</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="latest-row"><td>2023/24</td><td>60</td><td>88</td><td>68%</td><td>241</td></tr>
|
||||||
|
<tr><td>2022/23</td><td>60</td><td>74</td><td>81%</td><td>198</td></tr>
|
||||||
|
<tr><td>2021/22</td><td>60</td><td>63</td><td>95%</td><td>150</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ OPTION A (LITE) ============ -->
|
||||||
|
<div class="optlabel">Option A <span style="color:var(--text-muted);text-transform:none;letter-spacing:0;font-weight:500">· lite</span></div>
|
||||||
|
<p class="optdesc">Trend-aware headline + sparkline inside the existing card. Smallest change; shows the shape of the trend but not the per-year numbers.</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
|
||||||
|
|
||||||
|
<div style="font-weight:700;font-size:18px;letter-spacing:-.01em;margin-bottom:4px">
|
||||||
|
Getting harder to get into <span class="trendtag up">↗</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:var(--text-secondary);font-size:14px;margin-bottom:18px">Oversubscribed in each of the last 3 years.</div>
|
||||||
|
|
||||||
|
<div class="chartwrap">
|
||||||
|
<div class="chartcap">First-choice offer rate</div>
|
||||||
|
<svg class="chart" viewBox="0 0 520 150" role="img" aria-label="First-choice offer rate falling from 95% to 68%">
|
||||||
|
<polyline points="60,30 260,58 460,84" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="60" cy="30" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="260" cy="58" r="5" fill="var(--accent-coral)"/>
|
||||||
|
<circle cx="460" cy="84" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="60" y="20" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
|
||||||
|
<text x="260" y="48" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
|
||||||
|
<text x="460" y="74" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
|
||||||
|
<text x="60" y="120" text-anchor="middle" class="axisyr">2021/22</text>
|
||||||
|
<text x="260" y="120" text-anchor="middle" class="axisyr">2022/23</text>
|
||||||
|
<text x="460" y="120" text-anchor="middle" class="axisyr">2023/24</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="latest">Latest (2023/24): <b>60 places</b> · <b>88 first-choice</b> · 241 total applications.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ OPTION B (TABLE) ============ -->
|
||||||
|
<div class="optlabel">Option B <span style="color:var(--text-muted);text-transform:none;letter-spacing:0;font-weight:500">· table</span></div>
|
||||||
|
<p class="optdesc">Compact year-by-year table of every metric. Maximum transparency; leaves the parent to spot the trend themselves and is the densest on mobile.</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:18px">Three-year admissions history</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Year</th><th>Places</th><th>1st-pref apps</th><th>1st-choice rate</th><th>Total apps</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="latest-row"><td>2023/24</td><td>60</td><td>88</td><td>68%</td><td>241</td><td><span class="pill over">Over</span></td></tr>
|
||||||
|
<tr><td>2022/23</td><td>60</td><td>74</td><td>81%</td><td>198</td><td><span class="pill over">Over</span></td></tr>
|
||||||
|
<tr><td>2021/22</td><td>60</td><td>63</td><td>95%</td><td>150</td><td><span class="pill ok">OK</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note"><span class="i">i</span><span>A falling first-choice rate means competition is rising. “Places” is the number offered this round, roughly the intake size.</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const proposed = document.getElementById('proposed');
|
||||||
|
const views = { year: document.getElementById('view-year'), trend: document.getElementById('view-trend') };
|
||||||
|
const toggles = proposed.querySelectorAll('.seg button');
|
||||||
|
toggles.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const show = btn.dataset.show;
|
||||||
|
views.year.hidden = show !== 'year';
|
||||||
|
views.trend.hidden = show !== 'trend';
|
||||||
|
toggles.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.show === show)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>School header — “Emerge” map hero (desktop + mobile)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
||||||
|
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
||||||
|
--accent-coral:#e07256; --accent-coral-dark:#c45a3f; --accent-teal:#2d7d7d;
|
||||||
|
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06);
|
||||||
|
--serif:'Playfair Display',Georgia,serif; --sans:'DM Sans',-apple-system,sans-serif;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.55;padding:40px 20px 120px}
|
||||||
|
.wrap{max-width:1120px;margin:0 auto}
|
||||||
|
.pagehead h1{font-family:var(--serif);font-weight:700;font-size:30px;letter-spacing:-.01em}
|
||||||
|
.pagehead p{color:var(--text-secondary);margin-top:8px;font-size:15px;max-width:74ch}
|
||||||
|
.optlabel{display:flex;align-items:center;gap:10px;font-size:12px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--accent-teal);margin:52px 0 6px}
|
||||||
|
.optdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:74ch}
|
||||||
|
|
||||||
|
.stage{display:flex;gap:44px;align-items:flex-start;flex-wrap:wrap}
|
||||||
|
.col-desktop{flex:1 1 560px;min-width:420px}
|
||||||
|
.col-mobile{flex:0 0 auto}
|
||||||
|
.caption{font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);margin-bottom:12px}
|
||||||
|
|
||||||
|
.topBack{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 12px;font-size:17px;font-weight:600;color:var(--accent-coral)}
|
||||||
|
|
||||||
|
/* ── hero shell ── */
|
||||||
|
.hero{position:relative;background:var(--bg-card);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
|
||||||
|
.map{width:100%;background:#dfe6e2}
|
||||||
|
.map .leaflet-control-attribution{font-size:9px;background:rgba(255,255,255,.55)}
|
||||||
|
|
||||||
|
/* coral teardrop pin */
|
||||||
|
.pin{position:relative;width:26px;height:26px}
|
||||||
|
.pin svg{filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))}
|
||||||
|
.pin .ring{position:absolute;left:50%;top:72%;width:34px;height:34px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.28);animation:pulse 2.4s ease-out infinite}
|
||||||
|
@keyframes pulse{0%{transform:translate(-50%,-50%) scale(.5);opacity:.9}100%{transform:translate(-50%,-50%) scale(1.6);opacity:0}}
|
||||||
|
|
||||||
|
.compareBtn{position:absolute;z-index:500;top:14px;right:14px;font-size:13px;font-weight:600;padding:9px 15px;border-radius:8px;cursor:pointer;border:none;
|
||||||
|
background:rgba(255,255,255,.88);color:var(--accent-coral);backdrop-filter:blur(6px);box-shadow:0 2px 10px rgba(0,0,0,.16)}
|
||||||
|
|
||||||
|
/* the diffuse blend: tall gradient that reaches solid white well before the title */
|
||||||
|
.fade{position:absolute;left:0;right:0;z-index:400;pointer-events:none;
|
||||||
|
background:linear-gradient(to bottom,
|
||||||
|
rgba(255,255,255,0) 0%,
|
||||||
|
rgba(255,255,255,.35) 35%,
|
||||||
|
rgba(255,255,255,.75) 62%,
|
||||||
|
rgba(255,255,255,.95) 82%,
|
||||||
|
var(--bg-card) 100%)}
|
||||||
|
|
||||||
|
.body{position:relative;z-index:410}
|
||||||
|
.name{font-family:var(--serif);font-weight:700;line-height:1.08;letter-spacing:-.01em}
|
||||||
|
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||||
|
.chip{background:var(--bg-secondary);color:var(--text-secondary);font-size:13px;font-weight:500;padding:4px 11px;border-radius:6px}
|
||||||
|
.addr{display:flex;align-items:center;flex-wrap:wrap;gap:7px;color:var(--text-secondary);font-size:15px;margin-top:14px}
|
||||||
|
.addr .maplink{color:var(--accent-coral);font-weight:600;text-decoration:none;white-space:nowrap}
|
||||||
|
.details{display:flex;flex-wrap:wrap;gap:6px 22px;margin-top:14px;font-size:14px;color:var(--text-secondary)}
|
||||||
|
.details a{color:var(--accent-teal);font-weight:600;text-decoration:none}
|
||||||
|
.details b{color:var(--text-primary)}
|
||||||
|
.ofstedStrip{display:inline-block;margin-top:16px;padding:9px 14px;border-radius:8px;background:rgba(45,125,125,.10);border-left:3px solid var(--accent-teal)}
|
||||||
|
.ofstedStrip b{font-size:14px}.ofstedStrip span{display:block;font-size:12px;color:var(--text-muted)}
|
||||||
|
|
||||||
|
/* desktop sizing */
|
||||||
|
.col-desktop .map{height:210px}
|
||||||
|
.col-desktop .fade{top:70px;height:190px} /* white by ~map bottom (210) */
|
||||||
|
.col-desktop .body{margin-top:-6px;padding:0 26px 22px}
|
||||||
|
.col-desktop .name{font-size:clamp(30px,3.6vw,44px)}
|
||||||
|
|
||||||
|
/* mobile phone frame */
|
||||||
|
.phone{width:390px;border:10px solid #1a1612;border-radius:38px;overflow:hidden;box-shadow:0 18px 50px rgba(26,22,18,.22);background:var(--bg-primary)}
|
||||||
|
.phone .notch{height:26px;background:#1a1612;position:relative}
|
||||||
|
.phone .notch::after{content:"";position:absolute;left:50%;top:7px;transform:translateX(-50%);width:120px;height:20px;background:#1a1612;border-radius:0 0 14px 14px}
|
||||||
|
.phone .globalnav{height:46px;background:#fff;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;font-family:var(--serif);font-weight:700;font-size:17px}
|
||||||
|
.phone .screen{padding:14px 12px 26px}
|
||||||
|
.phone .hero .map{height:150px}
|
||||||
|
.phone .hero .fade{top:52px;height:132px} /* white by ~map bottom (150) */
|
||||||
|
.phone .hero .body{margin-top:-4px;padding:0 18px 18px}
|
||||||
|
.phone .hero .name{font-size:29px}
|
||||||
|
.phone .hero .compareBtn{top:12px;right:12px;padding:8px 12px;font-size:12.5px}
|
||||||
|
.phone .addr{font-size:14px}
|
||||||
|
.phone .details{font-size:13px;gap:4px 16px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="pagehead">
|
||||||
|
<h1>“Emerge” map hero — refined</h1>
|
||||||
|
<p>Transition made more diffuse: the map dissolves gradually and reaches solid white before the school name, so the title sits cleanly on white with only a soft memory of the map above it. Redundant floating pill removed — the only map CTA is the inline “View on map ↗”. Desktop and mobile shown side by side.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="optlabel">Option A · “Emerge” (refined)</div>
|
||||||
|
<p class="optdesc">Same school throughout (Our Lady Queen of Heaven, SW19 6AD). Live, non-interactive preview maps; the CTA opens the full map.</p>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<!-- ── DESKTOP ── -->
|
||||||
|
<div class="col-desktop">
|
||||||
|
<div class="caption">Desktop</div>
|
||||||
|
<a class="topBack">← Back</a>
|
||||||
|
<div class="hero" data-hero>
|
||||||
|
<button class="compareBtn">+ Add to Compare</button>
|
||||||
|
<div class="map" id="mapD"></div>
|
||||||
|
<div class="fade"></div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 class="name">Our Lady Queen of Heaven RC School</h1>
|
||||||
|
<div class="meta"><span class="chip">Wandsworth</span><span class="chip">Voluntary aided school</span></div>
|
||||||
|
<div class="addr">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#e07256" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
Victoria Drive, Southfields, London, SW19 6AD · <a class="maplink" data-open>View on map ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="details">
|
||||||
|
<span><b>Headteacher:</b> Mr Jeremy Tuke</span>
|
||||||
|
<a>School website ↗</a>
|
||||||
|
<span><b>Pupils:</b> 221 (capacity: 232)</span>
|
||||||
|
</div>
|
||||||
|
<div class="ofstedStrip"><b>Ofsted Outstanding</b><span>Inspected November 2023</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MOBILE ── -->
|
||||||
|
<div class="col-mobile">
|
||||||
|
<div class="caption">Mobile</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="notch"></div>
|
||||||
|
<div class="globalnav">SchoolCompare</div>
|
||||||
|
<div class="screen">
|
||||||
|
<a class="topBack" style="font-size:16px">← Back</a>
|
||||||
|
<div class="hero" data-hero>
|
||||||
|
<button class="compareBtn">+ Compare</button>
|
||||||
|
<div class="map" id="mapM"></div>
|
||||||
|
<div class="fade"></div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 class="name">Our Lady Queen of Heaven RC School</h1>
|
||||||
|
<div class="meta"><span class="chip">Wandsworth</span><span class="chip">Voluntary aided</span></div>
|
||||||
|
<div class="addr">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#e07256" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
Victoria Drive, Southfields, SW19 6AD
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px"><a class="maplink" data-open style="color:var(--accent-coral);font-weight:600;text-decoration:none;font-size:14px">View on map ↗</a></div>
|
||||||
|
<div class="details">
|
||||||
|
<span><b>Headteacher:</b> Mr Jeremy Tuke</span>
|
||||||
|
<a>School website ↗</a>
|
||||||
|
<span><b>Pupils:</b> 221 (cap. 232)</span>
|
||||||
|
</div>
|
||||||
|
<div class="ofstedStrip"><b>Ofsted Outstanding</b><span>Inspected November 2023</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="optdesc" style="margin-top:26px"><b style="color:var(--text-secondary)">No-location fallback</b> — schools without lat/long simply render the hero with no map band (title at the top as today), so the component degrades cleanly.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const LAT = 51.4459, LNG = -0.2076;
|
||||||
|
const pinIcon = L.divIcon({
|
||||||
|
className:'', iconSize:[26,34], iconAnchor:[13,30],
|
||||||
|
html:'<div class="pin"><span class="ring"></span><svg width="26" height="34" viewBox="0 0 26 34"><path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/><circle cx="13" cy="13" r="5" fill="#fff"/></svg></div>'
|
||||||
|
});
|
||||||
|
function makePreview(id){
|
||||||
|
const m = L.map(id,{zoomControl:false,attributionControl:true,dragging:false,scrollWheelZoom:false,doubleClickZoom:false,boxZoom:false,keyboard:false,touchZoom:false}).setView([LAT,LNG],15);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OpenStreetMap'}).addTo(m);
|
||||||
|
L.marker([LAT,LNG],{icon:pinIcon}).addTo(m);
|
||||||
|
setTimeout(()=>m.invalidateSize(),60);
|
||||||
|
}
|
||||||
|
['mapD','mapM'].forEach(makePreview);
|
||||||
|
document.querySelectorAll('[data-open]').forEach(el=>el.addEventListener('click',e=>{e.preventDefault();alert('→ opens the full interactive map');}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -22,7 +22,9 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
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
|
ARG FASTAPI_URL=http://backend:80/api
|
||||||
ENV FASTAPI_URL=${FASTAPI_URL}
|
ENV FASTAPI_URL=${FASTAPI_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
### Rankings Page
|
### Rankings Page
|
||||||
- [ ] Rankings page loads with SSR
|
- [ ] Rankings page loads with SSR
|
||||||
- [ ] Default metric displays (RWM Expected)
|
- [ ] Default metric displays (Reading, Writing & Maths Expected)
|
||||||
- [ ] Metric selector updates rankings
|
- [ ] Metric selector updates rankings
|
||||||
- [ ] Area filter updates rankings
|
- [ ] Area filter updates rankings
|
||||||
- [ ] Year filter updates rankings
|
- [ ] Year filter updates rankings
|
||||||
|
|||||||
@@ -34,24 +34,24 @@ describe('SchoolCard', () => {
|
|||||||
render(<SchoolCard school={mockSchool} />);
|
render(<SchoolCard school={mockSchool} />);
|
||||||
|
|
||||||
const link = screen.getByRole('link', { name: /test primary school/i });
|
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();
|
const mockAddToCompare = jest.fn();
|
||||||
render(<SchoolCard school={mockSchool} onAddToCompare={mockAddToCompare} />);
|
render(<SchoolCard school={mockSchool} onAddToCompare={mockAddToCompare} />);
|
||||||
|
|
||||||
const addButton = screen.getByText('Add to Compare');
|
const addButton = screen.getByText('+ Compare');
|
||||||
fireEvent.click(addButton);
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
expect(mockAddToCompare).toHaveBeenCalledWith(mockSchool);
|
expect(mockAddToCompare).toHaveBeenCalledWith(mockSchool);
|
||||||
expect(mockAddToCompare).toHaveBeenCalledTimes(1);
|
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} />);
|
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', () => {
|
it('displays trend indicator for positive change', () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user