Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79246edc22 | ||
|
|
64b63b96c8 | ||
|
|
5944d88f0b | ||
|
|
163b501be6 | ||
|
|
80176cac4d | ||
|
|
84baf95f68 | ||
|
|
99b769ca9e | ||
|
|
e8f78a1598 | ||
|
|
8e0b730629 | ||
|
|
20a27f3958 | ||
|
|
852ed11e4d | ||
|
|
77d7052662 | ||
|
|
c9e324635b | ||
|
|
1d855f3c17 | ||
|
|
9773483221 | ||
|
|
e00a1b38a8 | ||
|
|
026a7ab6aa | ||
|
|
a86a2be96c | ||
|
|
d98e88f0b4 | ||
|
|
609bb923d9 | ||
|
|
4bfcd9ba9a | ||
|
|
95f10bf352 | ||
|
|
674470ceb6 | ||
|
|
8abff7a0a1 | ||
|
|
6f62c25f47 | ||
|
|
e74d3882ce | ||
|
|
3fb3db1cc4 | ||
|
|
b4b0249a06 | ||
|
|
e39aef2935 | ||
|
|
fef83b3bf2 | ||
|
|
cf458fe05c | ||
|
|
f579630fab | ||
|
|
19b41b6999 | ||
|
|
66bc5523f6 | ||
|
|
3cb72d0a0f | ||
|
|
b89fa47ec5 | ||
|
|
0c7ad0f309 | ||
|
|
e4565e9f15 | ||
|
|
06e4898c30 | ||
|
|
a9611e21c3 | ||
|
|
0696518995 | ||
|
|
090d5f7bec | ||
|
|
315f1feede | ||
|
|
abc03a0dd3 | ||
|
|
43a2c4a6bc | ||
|
|
8e4ee64140 | ||
|
|
d2dc78aeb5 | ||
|
|
619e3a1189 | ||
|
|
52f8994401 | ||
|
|
9990f540f7 | ||
|
|
6dd9b04b50 | ||
|
|
d0e71e2cf0 | ||
|
|
6138e2b2ee | ||
|
|
2f85b9c647 | ||
|
|
2155256177 | ||
|
|
519584f34b | ||
|
|
2573cd2490 | ||
|
|
9f2260ce50 | ||
|
|
4a8e798c64 | ||
|
|
60cbc3f46d | ||
|
|
48ca042b08 | ||
|
|
80f057ea5a | ||
|
|
9447b80bc6 | ||
|
|
c0f31a5941 | ||
|
|
cec7941b44 | ||
|
|
dbaa15c099 | ||
|
|
b5b47ca135 | ||
|
|
0c89b2c34e | ||
|
|
4bf90b5f09 | ||
|
|
17b4498c80 | ||
|
|
3754947fd6 | ||
|
|
9799ad9b43 | ||
|
|
d5cd0abfee | ||
|
|
6877abedeb | ||
|
|
436ec6151b | ||
|
|
2b563cc0bf | ||
|
|
75e92dc7f5 | ||
|
|
7499e7f557 | ||
|
|
dd0ff7d0c2 | ||
|
|
6f925abf6b | ||
|
|
03518520f8 | ||
|
|
c2ed002118 | ||
|
|
02084e427c | ||
|
|
bee63a7836 | ||
|
|
fc21783298 | ||
|
|
ccd8e73fe8 | ||
|
|
5f1b6adb44 | ||
|
|
34a5de2687 | ||
|
|
af43b291e7 | ||
|
|
5a94f470e1 | ||
|
|
0fe1ea0d6a | ||
|
|
297bdbd12e | ||
|
|
58e90fef61 | ||
|
|
3710529e49 | ||
|
|
159207c6f5 | ||
|
|
d677b54533 | ||
|
|
c353e36072 | ||
|
|
d9223a6d6e | ||
|
|
74ca76d150 | ||
|
|
4b75152ee0 | ||
|
|
84dfc6c1bb | ||
|
|
c26755750d | ||
|
|
254a19eb42 | ||
|
|
4f6b2b0edc | ||
|
|
f1a013ec01 | ||
|
|
fa6c929a3a | ||
|
|
d898e6279b | ||
|
|
e188c2ff4b | ||
|
|
08bd86db05 | ||
|
|
1ae5762a0a | ||
|
|
bc87e56545 | ||
|
|
4522cbf645 | ||
|
|
7370712888 | ||
|
|
45ab479062 | ||
|
|
6f602f4a9e | ||
|
|
de81e9cdbd | ||
|
|
45c68b60b4 | ||
|
|
f1388ff5bd | ||
|
|
4d226fd616 | ||
|
|
a524cdc591 | ||
|
|
3fcb1340d4 | ||
|
|
0934c8f38c | ||
|
|
1d149ffc48 | ||
|
|
d11faefebd | ||
|
|
3b35849bb3 | ||
|
|
87f4c6dd40 | ||
|
|
0309b27c84 | ||
|
|
85484a80c4 | ||
|
|
536832a524 | ||
|
|
87642b7b06 | ||
|
|
929748d014 | ||
|
|
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 |
@@ -1,12 +1,9 @@
|
|||||||
name: Build and Push Docker Images
|
name: Stage (build -> staging -> E2E gate)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: privaterepo.sitaru.org
|
REGISTRY: privaterepo.sitaru.org
|
||||||
@@ -45,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
|
||||||
@@ -91,24 +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-pipeline:
|
build-pipeline:
|
||||||
name: Build Pipeline (Meltano + dbt + Airflow)
|
name: Build Pipeline (Meltano + dbt + Airflow)
|
||||||
@@ -140,28 +131,67 @@ 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-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 }}
|
||||||
|
|
||||||
|
# Production deployment is a second, manual approval: see promote.yml
|
||||||
|
# ("Promote to Production (manual)") and docs/DEPLOY.md.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
# Cancel superseded runs: pushing a new commit to a PR (or an empty
|
||||||
|
# re-trigger) aborts the previous still-running checks instead of running
|
||||||
|
# a second full matrix alongside them.
|
||||||
|
concurrency:
|
||||||
|
group: pr-checks-${{ gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
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 the resolved node_modules (452 MB / 460 packages) keyed on the
|
||||||
|
# lockfile. On a hit — the common case, since deps change rarely — the
|
||||||
|
# whole `npm ci` step is skipped, not just its download phase. The key
|
||||||
|
# pins OS + node major so we never restore incompatible native binaries.
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: node-modules-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: nextjs-app/node_modules
|
||||||
|
key: nextjs-node-modules-${{ runner.os }}-node22-${{ hashFiles('nextjs-app/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: nextjs-app
|
||||||
|
run: npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
- 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 pytest "httpx<0.28"
|
||||||
|
|
||||||
|
- name: Import smoke test
|
||||||
|
run: python -c "from backend.app import app; print('backend imports OK')"
|
||||||
|
|
||||||
|
- name: Backend unit tests
|
||||||
|
run: python -m pytest backend/tests -q
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
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
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: Promote to Production (manual)
|
||||||
|
|
||||||
|
# Second approval gate of the deploy model: run this workflow from the
|
||||||
|
# Actions UI after testing the feature on staging. It refuses commits
|
||||||
|
# whose staging E2E gate is not green. See docs/DEPLOY.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sha:
|
||||||
|
description: >-
|
||||||
|
Commit SHA on main to promote (full or >=7 chars).
|
||||||
|
Leave empty to promote the latest main commit.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
# Only one promotion at a time; never cancel an in-flight promotion.
|
||||||
|
concurrency:
|
||||||
|
group: prod-promotion
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: privaterepo.sitaru.org
|
||||||
|
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||||
|
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||||
|
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote-prod:
|
||||||
|
name: Promote approved commit to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository (full history for ancestry check)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve and validate target SHA
|
||||||
|
id: resolve
|
||||||
|
# SECURITY: the dispatch input is untrusted — it reaches the shell
|
||||||
|
# only via env (never spliced into `run:` with ${{ }}) and is only
|
||||||
|
# used as a quoted argument. The resolved value is validated as a
|
||||||
|
# 40-hex sha and required to be an ancestor of main before any
|
||||||
|
# later step interpolates it.
|
||||||
|
env:
|
||||||
|
SHA_INPUT: ${{ gitea.event.inputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "$SHA_INPUT" in
|
||||||
|
-*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$SHA_INPUT" ]; then
|
||||||
|
SHA_INPUT="$(git rev-parse origin/main)"
|
||||||
|
fi
|
||||||
|
FULL_SHA=$(git rev-parse --verify --quiet "${SHA_INPUT}^{commit}") || {
|
||||||
|
echo "REFUSED: not a commit in this repository." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "$FULL_SHA" | grep -Eq '^[0-9a-f]{40}$'
|
||||||
|
if ! git merge-base --is-ancestor "$FULL_SHA" origin/main; then
|
||||||
|
echo "REFUSED: $FULL_SHA is not on main — only main commits are promotable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
|
||||||
|
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
||||||
|
|
||||||
|
- name: Verify the staging E2E gate passed for this commit
|
||||||
|
run: |
|
||||||
|
STATUS_JSON=$(curl -fsS \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
||||||
|
echo "$STATUS_JSON" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
ok = [s for s in d.get('statuses', [])
|
||||||
|
if 'E2E Journeys against Staging' in s.get('context', '')
|
||||||
|
and s.get('status') == 'success']
|
||||||
|
if not ok:
|
||||||
|
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
|
||||||
|
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
|
||||||
|
sys.exit(1)
|
||||||
|
print('E2E gate verified green for this commit.')
|
||||||
|
"
|
||||||
|
|
||||||
|
- 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 approved images as prod (keeping rollback pointer)
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${{ steps.resolve.outputs.short }}"
|
||||||
|
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 }}
|
||||||
+6
-1
@@ -1,2 +1,7 @@
|
|||||||
venv
|
venv
|
||||||
backend/__pycache__
|
__pycache__/
|
||||||
|
|
||||||
|
# dbt local build artifacts (embed absolute paths + anonymous-usage UUID)
|
||||||
|
pipeline/transform/target/
|
||||||
|
pipeline/transform/logs/
|
||||||
|
pipeline/transform/.user.yml
|
||||||
|
|||||||
@@ -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.
|
||||||
+273
-125
@@ -4,6 +4,7 @@ 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
|
||||||
@@ -23,14 +25,17 @@ import asyncio
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .data_loader import (
|
from .data_loader import (
|
||||||
clear_cache,
|
clear_cache,
|
||||||
|
compute_benchmarks,
|
||||||
load_school_data,
|
load_school_data,
|
||||||
|
load_latest_school_data,
|
||||||
geocode_single_postcode,
|
geocode_single_postcode,
|
||||||
get_supplementary_data,
|
get_supplementary_data,
|
||||||
|
get_supplementary_data_batch,
|
||||||
search_schools_typesense,
|
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 .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, convert_to_native
|
||||||
|
|
||||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||||
@@ -164,6 +169,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."""
|
||||||
|
|
||||||
@@ -223,6 +291,8 @@ async def lifespan(app: FastAPI):
|
|||||||
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
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:
|
try:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemap_xml = build_sitemap()
|
||||||
n = _sitemap_xml.count("<url>")
|
n = _sitemap_xml.count("<url>")
|
||||||
@@ -250,9 +320,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(
|
||||||
@@ -321,44 +394,17 @@ async def get_schools(
|
|||||||
phase = sanitize_search_input(phase)
|
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
|
||||||
|
|
||||||
# Schools with no performance data (special schools, PRUs, newly opened, etc.)
|
|
||||||
# have NULL year from the LEFT JOIN — keep them but skip the groupby/trend logic.
|
|
||||||
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
|
||||||
df = df[df["year"].notna()]
|
|
||||||
|
|
||||||
# Get unique schools (latest year data for each)
|
|
||||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
|
||||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
|
||||||
|
|
||||||
# Calculate trend by comparing to previous year
|
|
||||||
# Get second-latest year for each school
|
|
||||||
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
|
|
||||||
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
|
||||||
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
|
||||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
|
||||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
|
||||||
)
|
|
||||||
if "attainment_8_score" in df_prev.columns:
|
|
||||||
prev_rwm = prev_rwm.merge(
|
|
||||||
df_prev[["urn", "attainment_8_score"]].rename(
|
|
||||||
columns={"attainment_8_score": "prev_attainment_8_score"}
|
|
||||||
),
|
|
||||||
on="urn", how="outer"
|
|
||||||
)
|
|
||||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
|
||||||
|
|
||||||
# Merge back schools with no performance data
|
|
||||||
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
|
||||||
|
|
||||||
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||||
# in the correct phase(s) rather than being invisible to both filters.
|
# in the correct phase(s) rather than being invisible to both filters.
|
||||||
if phase:
|
if phase:
|
||||||
@@ -372,10 +418,17 @@ async def get_schools(
|
|||||||
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
||||||
if admissions_policy:
|
if admissions_policy:
|
||||||
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
||||||
if has_sixth_form == "yes":
|
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
|
||||||
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
# yet populated by the pipeline) is treated as "no sixth form".
|
||||||
elif has_sixth_form == "no":
|
if has_sixth_form in ("yes", "no"):
|
||||||
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
if "has_sixth_form" in df_latest.columns:
|
||||||
|
flag = df_latest["has_sixth_form"].eq(True)
|
||||||
|
else: # Defensive fallback only — data_loader now always synthesizes
|
||||||
|
# has_sixth_form as NULL when the DB predates the pipeline re-run,
|
||||||
|
# so this branch shouldn't normally trigger. Falls back to age
|
||||||
|
# range if the column is somehow absent anyway.
|
||||||
|
flag = df_latest["age_range"].str.contains("18", na=False)
|
||||||
|
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
|
||||||
|
|
||||||
# Include key result metrics for display on cards
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
@@ -404,7 +457,8 @@ async def get_schools(
|
|||||||
# 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()
|
||||||
@@ -527,7 +581,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:
|
||||||
@@ -537,8 +591,13 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
# Schools with no performance rows (post-16 institutions, PRUs, new
|
||||||
"school_info": {
|
# schools) carry NaN in every LEFT-JOINed numeric column; NaN reaching
|
||||||
|
# JSONResponse raises ValueError, so school_info needs the same
|
||||||
|
# conversion yearly_data gets from clean_for_json.
|
||||||
|
school_info = {
|
||||||
|
k: convert_to_native(v)
|
||||||
|
for k, v in {
|
||||||
"urn": urn,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
@@ -546,6 +605,8 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"address": latest.get("address", ""),
|
"address": latest.get("address", ""),
|
||||||
"religious_denomination": latest.get("religious_denomination", ""),
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
"status": latest.get("status"),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": latest.get("phase"),
|
"phase": latest.get("phase"),
|
||||||
@@ -556,13 +617,17 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
},
|
}.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"school_info": school_info,
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
"parent_view": supplementary.get("parent_view"),
|
|
||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
"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"),
|
||||||
@@ -599,6 +664,62 @@ async def compare_schools(
|
|||||||
if comparison_data.empty:
|
if comparison_data.empty:
|
||||||
raise HTTPException(status_code=404, detail="No schools found")
|
raise HTTPException(status_code=404, detail="No schools found")
|
||||||
|
|
||||||
|
# One session for all schools' supplementary blocks; failures degrade
|
||||||
|
# to empty blocks rather than failing a working comparison (mirrors
|
||||||
|
# the detail endpoint's defensive pattern).
|
||||||
|
from . import database
|
||||||
|
|
||||||
|
_EMPTY_SUPPLEMENTARY = {
|
||||||
|
"ofsted": None,
|
||||||
|
"census": None,
|
||||||
|
"admissions": None,
|
||||||
|
"admissions_history": [],
|
||||||
|
"deprivation": None,
|
||||||
|
}
|
||||||
|
supplementary_by_urn: dict = {}
|
||||||
|
census_benchmarks = None
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = database.SessionLocal()
|
||||||
|
# One query per table for all schools, not ~5 queries per school.
|
||||||
|
batch = get_supplementary_data_batch(db, urn_list)
|
||||||
|
for urn in urn_list:
|
||||||
|
supp = batch.get(urn, {})
|
||||||
|
supplementary_by_urn[urn] = {
|
||||||
|
key: supp.get(key, default)
|
||||||
|
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
||||||
|
}
|
||||||
|
# Import-time census context benchmarks (fact_census_benchmarks);
|
||||||
|
# absent mart → None, and compute_benchmarks leaves those fields null.
|
||||||
|
try:
|
||||||
|
from .models import CensusBenchmark
|
||||||
|
|
||||||
|
rows = db.query(CensusBenchmark).all()
|
||||||
|
by_phase = {
|
||||||
|
r.phase: {
|
||||||
|
"year": r.year,
|
||||||
|
"fsm_pct": r.fsm_pct,
|
||||||
|
"eal_pct": r.eal_pct,
|
||||||
|
"median_pupils": r.median_pupils,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
if getattr(r, "phase", None) in ("primary", "secondary")
|
||||||
|
}
|
||||||
|
if by_phase:
|
||||||
|
census_benchmarks = by_phase
|
||||||
|
except Exception:
|
||||||
|
# Missing mart (or a stubbed session in tests) must never break
|
||||||
|
# the compare payload — and not every session has rollback().
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
supplementary_by_urn = {}
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for urn in urn_list:
|
for urn in urn_list:
|
||||||
school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year")
|
school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year")
|
||||||
@@ -614,11 +735,30 @@ async def compare_schools(
|
|||||||
"phase": latest.get("phase", ""),
|
"phase": latest.get("phase", ""),
|
||||||
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
|
"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,
|
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
|
||||||
|
# GIAS facts the compare "Who goes there" section needs
|
||||||
|
# (same fields the detail endpoint exposes)
|
||||||
|
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
||||||
|
"age_range": convert_to_native(latest.get("age_range")),
|
||||||
|
"gender": convert_to_native(latest.get("gender")),
|
||||||
|
# Needed by the admissions "What this means" copy: selective
|
||||||
|
# schools get entrance-test framing, never the distance template.
|
||||||
|
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
||||||
|
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||||
|
"capacity": convert_to_native(latest.get("capacity")),
|
||||||
|
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||||
|
"trust_name": convert_to_native(latest.get("trust_name")),
|
||||||
},
|
},
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
|
**supplementary_by_urn.get(urn, dict(_EMPTY_SUPPLEMENTARY)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"comparison": result}
|
return {
|
||||||
|
"comparison": result,
|
||||||
|
# Official DfE anchors + computed state-school benchmarks so the
|
||||||
|
# compare UI can label provenance correctly (spec §8.6).
|
||||||
|
"national_averages": _national_averages_payload(df),
|
||||||
|
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/filters")
|
@app.get("/api/filters")
|
||||||
@@ -664,96 +804,84 @@ async def get_la_averages(request: Request):
|
|||||||
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/national-averages")
|
_KS2_NATIONAL_METRICS = [
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
"rwm_expected_pct", "rwm_high_pct",
|
||||||
async def get_national_averages(request: Request):
|
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||||
|
"gps_expected_pct", "gps_high_pct", "science_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_NATIONAL_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 _national_averages_payload(df: pd.DataFrame) -> dict:
|
||||||
|
"""National-averages payload shared by /api/national-averages and
|
||||||
|
/api/compare.
|
||||||
|
|
||||||
|
Both series are persisted marts computed at import time: official DfE
|
||||||
|
KS2 figures (fact_ks2_national_averages) and official DfE KS4 figures
|
||||||
|
(fact_ks4_national_averages) — the API never aggregates the performance
|
||||||
|
dataframe per request. If the KS4 mart hasn't been built yet, the
|
||||||
|
secondary series is empty — never a computed stand-in, because the UI
|
||||||
|
labels these figures as official DfE data.
|
||||||
"""
|
"""
|
||||||
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:
|
if df.empty:
|
||||||
return {"primary": {}, "secondary": {}}
|
return {"primary": {}, "secondary": {}}
|
||||||
|
|
||||||
ks2_metrics = [
|
latest_year = int(df["year"].max())
|
||||||
"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):
|
from . import database
|
||||||
|
from .models import Ks2NationalAverage, Ks4NationalAverage
|
||||||
|
|
||||||
|
def _row_metrics(row, metric_list):
|
||||||
out = {}
|
out = {}
|
||||||
for col in metric_list:
|
for col in metric_list:
|
||||||
if col in sub_df.columns:
|
val = getattr(row, col, None)
|
||||||
val = sub_df[col].dropna()
|
if val is not None:
|
||||||
if len(val) > 0:
|
out[col] = val
|
||||||
out[col] = round(float(val.mean()), 2)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
latest_year = int(df["year"].max())
|
ks2_rows: list = []
|
||||||
df_latest = df[df["year"] == latest_year]
|
ks4_rows: list = []
|
||||||
|
db = None
|
||||||
# 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:
|
try:
|
||||||
db = SessionLocal()
|
db = database.SessionLocal()
|
||||||
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
try:
|
||||||
# Build a lookup of computed secondary averages per year as fallback
|
ks2_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||||
secondary_by_year = {}
|
except Exception:
|
||||||
for yr in sorted(df["year"].dropna().unique()):
|
db.rollback()
|
||||||
yr = int(yr)
|
try:
|
||||||
df_yr = df[df["year"] == yr]
|
ks4_rows = db.query(Ks4NationalAverage).order_by(Ks4NationalAverage.year).all()
|
||||||
secondary_by_year[yr] = _means(
|
except Exception:
|
||||||
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
|
db.rollback()
|
||||||
)
|
except Exception:
|
||||||
# Merge: official KS2 figures + computed KS4 figures per year
|
pass
|
||||||
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:
|
finally:
|
||||||
db.close()
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
# Update latest_primary with official DfE figure for the latest year if available
|
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
|
||||||
if by_year:
|
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
|
||||||
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
|
|
||||||
if latest_official:
|
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
|
||||||
latest_primary = latest_official
|
by_year = [
|
||||||
|
{
|
||||||
|
"year": yr,
|
||||||
|
"primary": primary_by_year.get(yr, {}),
|
||||||
|
"secondary": secondary_by_year.get(yr, {}),
|
||||||
|
}
|
||||||
|
for yr in all_years
|
||||||
|
]
|
||||||
|
|
||||||
|
latest_primary = next((e["primary"] for e in reversed(by_year) if e["primary"]), {})
|
||||||
|
latest_secondary = next((e["secondary"] for e in reversed(by_year) if e["secondary"]), {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"year": latest_year,
|
"year": latest_year,
|
||||||
@@ -763,6 +891,17 @@ async def get_national_averages(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/national-averages")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def get_national_averages(request: Request):
|
||||||
|
"""
|
||||||
|
National averages: official DfE KS2 figures per year plus computed
|
||||||
|
KS4 averages, derived from the loaded DataFrame and the
|
||||||
|
fact_ks2_national_averages mart.
|
||||||
|
"""
|
||||||
|
return _national_averages_payload(load_school_data())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/metrics")
|
@app.get("/api/metrics")
|
||||||
@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):
|
||||||
@@ -788,7 +927,10 @@ async def get_rankings(
|
|||||||
request: Request,
|
request: Request,
|
||||||
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
||||||
year: Optional[int] = Query(
|
year: Optional[int] = Query(
|
||||||
None, 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(
|
||||||
@@ -841,7 +983,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,
|
||||||
@@ -907,7 +1054,8 @@ 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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+474
-121
@@ -3,21 +3,58 @@ Data loading module — reads from marts.* tables built by dbt.
|
|||||||
Provides efficient queries with caching.
|
Provides efficient queries with caching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional, Dict, Tuple, List
|
from typing import Optional, Dict, Tuple, List
|
||||||
import requests
|
import requests
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
import sqlalchemy.exc
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import SessionLocal, engine
|
from .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactParentView, FactAdmissions,
|
FactOfstedInspection, FactAdmissions,
|
||||||
FactDeprivation, FactFinance,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
|
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||||
from .schemas import SCHOOL_TYPE_MAP
|
from .schemas import SCHOOL_TYPE_MAP
|
||||||
|
from .gias_codes import (
|
||||||
|
ADMISSIONS_POLICY,
|
||||||
|
ESTABLISHMENT_STATUS,
|
||||||
|
PHASE_OF_EDUCATION,
|
||||||
|
RELIGIOUS_CHARACTER,
|
||||||
|
SCHOOL_TYPE,
|
||||||
|
translate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# mart code column -> (API name column, dictionary)
|
||||||
|
_GIAS_CODE_COLUMNS = {
|
||||||
|
"phase_code": ("phase", PHASE_OF_EDUCATION),
|
||||||
|
"school_type_code": ("school_type", SCHOOL_TYPE),
|
||||||
|
"status_code": ("status", ESTABLISHMENT_STATUS),
|
||||||
|
"religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER),
|
||||||
|
"admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Map GIAS code columns to today's name columns (API contract).
|
||||||
|
|
||||||
|
Runs immediately after pd.read_sql so every downstream consumer —
|
||||||
|
filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names.
|
||||||
|
DataFrames without the code columns (old schema, test fixtures) pass
|
||||||
|
through unchanged.
|
||||||
|
"""
|
||||||
|
for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items():
|
||||||
|
if code_col in df.columns:
|
||||||
|
df[name_col] = df[code_col].map(lambda c: translate(c, mapping))
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
_postcode_cache: Dict[str, Tuple[float, float]] = {}
|
_postcode_cache: Dict[str, Tuple[float, float]] = {}
|
||||||
_typesense_client = None
|
_typesense_client = None
|
||||||
@@ -118,14 +155,16 @@ _MAIN_QUERY = text("""
|
|||||||
SELECT
|
SELECT
|
||||||
s.urn,
|
s.urn,
|
||||||
s.school_name,
|
s.school_name,
|
||||||
s.phase,
|
s.phase_code,
|
||||||
s.school_type,
|
s.school_type_code,
|
||||||
s.academy_trust_name AS trust_name,
|
s.academy_trust_name AS trust_name,
|
||||||
s.academy_trust_uid AS trust_uid,
|
s.academy_trust_uid AS trust_uid,
|
||||||
s.religious_character AS religious_denomination,
|
s.religious_character_code,
|
||||||
s.gender,
|
s.gender,
|
||||||
s.age_range,
|
s.age_range,
|
||||||
s.admissions_policy,
|
s.has_sixth_form,
|
||||||
|
s.status_code,
|
||||||
|
s.admissions_policy_code,
|
||||||
s.capacity,
|
s.capacity,
|
||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
@@ -152,13 +191,20 @@ _MAIN_QUERY = text("""
|
|||||||
p.reading_high_pct,
|
p.reading_high_pct,
|
||||||
p.reading_avg_score,
|
p.reading_avg_score,
|
||||||
p.reading_progress,
|
p.reading_progress,
|
||||||
|
p.reading_progress_lower_ci,
|
||||||
|
p.reading_progress_upper_ci,
|
||||||
p.writing_expected_pct,
|
p.writing_expected_pct,
|
||||||
p.writing_high_pct,
|
p.writing_high_pct,
|
||||||
p.writing_progress,
|
p.writing_progress,
|
||||||
|
p.writing_progress_lower_ci,
|
||||||
|
p.writing_progress_upper_ci,
|
||||||
|
p.writing_working_towards_pct,
|
||||||
p.maths_expected_pct,
|
p.maths_expected_pct,
|
||||||
p.maths_high_pct,
|
p.maths_high_pct,
|
||||||
p.maths_avg_score,
|
p.maths_avg_score,
|
||||||
p.maths_progress,
|
p.maths_progress,
|
||||||
|
p.maths_progress_lower_ci,
|
||||||
|
p.maths_progress_upper_ci,
|
||||||
p.gps_expected_pct,
|
p.gps_expected_pct,
|
||||||
p.gps_high_pct,
|
p.gps_high_pct,
|
||||||
p.gps_avg_score,
|
p.gps_avg_score,
|
||||||
@@ -187,6 +233,9 @@ _MAIN_QUERY = text("""
|
|||||||
p.progress_8_maths,
|
p.progress_8_maths,
|
||||||
p.progress_8_ebacc,
|
p.progress_8_ebacc,
|
||||||
p.progress_8_open,
|
p.progress_8_open,
|
||||||
|
p.progress_8_banding,
|
||||||
|
p.attainment_8_disadvantage_gap,
|
||||||
|
p.progress_8_disadvantage_gap,
|
||||||
p.english_maths_strong_pass_pct,
|
p.english_maths_strong_pass_pct,
|
||||||
p.english_maths_standard_pass_pct,
|
p.english_maths_standard_pass_pct,
|
||||||
p.ebacc_entry_pct,
|
p.ebacc_entry_pct,
|
||||||
@@ -204,7 +253,8 @@ _MAIN_QUERY = text("""
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DISTINCT ON (urn)
|
SELECT DISTINCT ON (urn)
|
||||||
urn,
|
urn,
|
||||||
overall_effectiveness AS ofsted_grade,
|
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
||||||
|
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
||||||
inspection_date AS ofsted_date,
|
inspection_date AS ofsted_date,
|
||||||
framework AS ofsted_framework
|
framework AS ofsted_framework
|
||||||
FROM marts.fact_ofsted_inspection
|
FROM marts.fact_ofsted_inspection
|
||||||
@@ -213,11 +263,92 @@ _MAIN_QUERY = text("""
|
|||||||
ORDER BY s.school_name, p.year
|
ORDER BY s.school_name, p.year
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Fallback used when marts.dim_school predates the has_sixth_form column
|
||||||
|
# (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on this DB).
|
||||||
|
# Keeps the column present as NULL so downstream code — including the
|
||||||
|
# app.py fallback branch — behaves as designed instead of KeyError-ing.
|
||||||
|
_MAIN_QUERY_NO_SIXTH_FORM = text(
|
||||||
|
str(_MAIN_QUERY).replace("s.has_sixth_form,", "NULL AS has_sixth_form,")
|
||||||
|
)
|
||||||
|
assert "NULL AS has_sixth_form" in str(_MAIN_QUERY_NO_SIXTH_FORM), (
|
||||||
|
"expected replacement of 's.has_sixth_form,' to have taken effect"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback used when marts.dim_school predates the GIAS code-dictionary
|
||||||
|
# migration (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on
|
||||||
|
# this DB, so it still has the old name columns instead of *_code columns).
|
||||||
|
_MAIN_QUERY_LEGACY_NAMES = str(_MAIN_QUERY)
|
||||||
|
_LEGACY_NAME_REPLACEMENTS = [
|
||||||
|
("s.phase_code,", "s.phase,"),
|
||||||
|
("s.school_type_code,", "s.school_type,"),
|
||||||
|
(
|
||||||
|
"s.religious_character_code,",
|
||||||
|
"s.religious_character AS religious_denomination,",
|
||||||
|
),
|
||||||
|
("s.status_code,", "s.status,"),
|
||||||
|
("s.admissions_policy_code,", "s.admissions_policy,"),
|
||||||
|
]
|
||||||
|
for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
||||||
|
assert _old in _MAIN_QUERY_LEGACY_NAMES, (
|
||||||
|
f"expected {_old!r} to be present in _MAIN_QUERY before replacement"
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||||
|
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||||
|
|
||||||
|
_GIAS_CODE_COLUMN_NAMES = (
|
||||||
|
"phase_code",
|
||||||
|
"school_type_code",
|
||||||
|
"religious_character_code",
|
||||||
|
"status_code",
|
||||||
|
"admissions_policy_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||||
|
"""Name of the missing column from a psycopg2 UndefinedColumn error.
|
||||||
|
|
||||||
|
Inspects exc.orig (the DBAPI error), whose message names only the
|
||||||
|
offending column — str(exc) also embeds the full SQL statement, which
|
||||||
|
contains every column name and therefore must not be matched against.
|
||||||
|
"""
|
||||||
|
orig = getattr(exc, "orig", None)
|
||||||
|
match = _MISSING_COLUMN_RE.search(str(orig) if orig is not None else str(exc))
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
def load_school_data_as_dataframe() -> pd.DataFrame:
|
def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||||
"""Load all school + KS2 data as a pandas DataFrame."""
|
"""Load all school + KS2 data as a pandas DataFrame."""
|
||||||
try:
|
try:
|
||||||
df = pd.read_sql(_MAIN_QUERY, engine)
|
df = pd.read_sql(_MAIN_QUERY, engine)
|
||||||
|
except sqlalchemy.exc.ProgrammingError as exc:
|
||||||
|
missing = _missing_column_name(exc)
|
||||||
|
if missing in _GIAS_CODE_COLUMN_NAMES:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"marts predate the GIAS code migration — falling back to "
|
||||||
|
"legacy name-column query: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql(_MAIN_QUERY_LEGACY_NAMES, engine)
|
||||||
|
except Exception as exc2:
|
||||||
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
elif missing == "has_sixth_form":
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"marts.dim_school is missing has_sixth_form (pipeline hasn't "
|
||||||
|
"rebuilt the mart yet on this DB) — retrying without it: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql(_MAIN_QUERY_NO_SIXTH_FORM, engine)
|
||||||
|
except Exception as exc2:
|
||||||
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
else:
|
||||||
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
|
return pd.DataFrame()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Warning: Could not load school data from marts: {exc}")
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -225,6 +356,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
if df.empty:
|
if df.empty:
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
df = translate_gias_code_columns(df)
|
||||||
|
|
||||||
# Build address string
|
# Build address string
|
||||||
df["address"] = df.apply(
|
df["address"] = df.apply(
|
||||||
lambda r: ", ".join(
|
lambda r: ", ".join(
|
||||||
@@ -242,6 +375,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
|
|
||||||
# Cache for DataFrame
|
# Cache for DataFrame
|
||||||
_df_cache: Optional[pd.DataFrame] = None
|
_df_cache: Optional[pd.DataFrame] = None
|
||||||
|
# Pre-computed latest-year snapshot (one row per school, with prev-year trend columns)
|
||||||
|
_df_latest_cache: Optional[pd.DataFrame] = None
|
||||||
|
|
||||||
|
|
||||||
def load_school_data() -> pd.DataFrame:
|
def load_school_data() -> pd.DataFrame:
|
||||||
@@ -260,10 +395,60 @@ def load_school_data() -> pd.DataFrame:
|
|||||||
return _df_cache
|
return _df_cache
|
||||||
|
|
||||||
|
|
||||||
|
def load_latest_school_data() -> pd.DataFrame:
|
||||||
|
"""Return a cached one-row-per-school DataFrame at the latest available year.
|
||||||
|
|
||||||
|
The expensive groupby / merge / prev-year trend computation runs once at
|
||||||
|
startup (or after a cache clear) rather than on every search request.
|
||||||
|
Per-request filters (phase, gender, LA …) should be applied to the returned
|
||||||
|
DataFrame's copy; they must NOT modify the cached object.
|
||||||
|
"""
|
||||||
|
global _df_latest_cache
|
||||||
|
if _df_latest_cache is not None:
|
||||||
|
return _df_latest_cache
|
||||||
|
|
||||||
|
df = load_school_data()
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Schools that have no performance rows (PRUs, new schools, etc.)
|
||||||
|
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
||||||
|
df_with_perf = df[df["year"].notna()]
|
||||||
|
|
||||||
|
# Reduce to the latest year per school
|
||||||
|
latest_year = df_with_perf.groupby("urn")["year"].max().reset_index()
|
||||||
|
df_latest = df_with_perf.merge(latest_year, on=["urn", "year"])
|
||||||
|
|
||||||
|
# Attach previous-year metrics for trend arrows (second-latest year per school)
|
||||||
|
df_sorted = df_with_perf.sort_values(["urn", "year"], ascending=[True, False])
|
||||||
|
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
||||||
|
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
||||||
|
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||||
|
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||||
|
)
|
||||||
|
if "attainment_8_score" in df_prev.columns:
|
||||||
|
prev_rwm = prev_rwm.merge(
|
||||||
|
df_prev[["urn", "attainment_8_score"]].rename(
|
||||||
|
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||||
|
),
|
||||||
|
on="urn",
|
||||||
|
how="outer",
|
||||||
|
)
|
||||||
|
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||||
|
|
||||||
|
# Merge back schools with no performance data
|
||||||
|
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
||||||
|
|
||||||
|
print(f"Latest-snapshot cache built: {len(df_latest)} schools")
|
||||||
|
_df_latest_cache = df_latest
|
||||||
|
return _df_latest_cache
|
||||||
|
|
||||||
|
|
||||||
def clear_cache():
|
def clear_cache():
|
||||||
"""Clear all caches."""
|
"""Clear all caches."""
|
||||||
global _df_cache
|
global _df_cache, _df_latest_cache
|
||||||
_df_cache = None
|
_df_cache = None
|
||||||
|
_df_latest_cache = None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -340,128 +525,296 @@ def get_data_info(db: Session = None) -> dict:
|
|||||||
# SUPPLEMENTARY DATA — per-school detail page
|
# SUPPLEMENTARY DATA — per-school detail page
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def get_supplementary_data(db: Session, urn: int) -> dict:
|
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||||
"""Fetch all supplementary data for a single school URN."""
|
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
|
||||||
result = {}
|
|
||||||
|
|
||||||
def safe_query(model, pk_field, latest_field=None):
|
NOT official DfE figures — consumers must label them
|
||||||
|
"state-school average (computed from our dataset)". The disadvantaged
|
||||||
|
attainment average is weighted by cohort size (eligible_pupils) so
|
||||||
|
small schools don't dominate.
|
||||||
|
|
||||||
|
Context measures (FSM/EAL/pupil counts) come from `census_benchmarks`
|
||||||
|
(the fact_census_benchmarks mart, pupil-weighted, keyed by phase): the
|
||||||
|
performance df has no fsm_pct at all, and its eal/disadvantaged columns
|
||||||
|
are KS2-only — medianing them for "secondary" produced junk anchors
|
||||||
|
from the handful of all-through schools. When the mart is unavailable
|
||||||
|
these are None; never fall back across measure definitions.
|
||||||
|
"""
|
||||||
|
if df.empty or "year" not in df.columns:
|
||||||
|
return {}
|
||||||
|
latest_year = df["year"].max()
|
||||||
|
if pd.isna(latest_year):
|
||||||
|
return {}
|
||||||
|
d = df[df["year"] == latest_year]
|
||||||
|
if d.empty:
|
||||||
|
return {}
|
||||||
|
is_secondary = (
|
||||||
|
d["attainment_8_score"].notna()
|
||||||
|
if "attainment_8_score" in d.columns
|
||||||
|
else pd.Series(False, index=d.index)
|
||||||
|
)
|
||||||
|
prim, sec = d[~is_secondary], d[is_secondary]
|
||||||
|
|
||||||
|
def _median(sub, col):
|
||||||
|
if col not in sub.columns:
|
||||||
|
return None
|
||||||
|
v = sub[col].median()
|
||||||
|
return round(float(v), 1) if pd.notna(v) else None
|
||||||
|
|
||||||
|
def _weighted_disadvantaged(sub):
|
||||||
|
needed = {"rwm_expected_disadvantaged_pct", "eligible_pupils"}
|
||||||
|
if not needed <= set(sub.columns):
|
||||||
|
return None
|
||||||
|
s = sub.dropna(subset=list(needed))
|
||||||
|
if s.empty or s["eligible_pupils"].sum() == 0:
|
||||||
|
return None
|
||||||
|
w = (
|
||||||
|
(s["rwm_expected_disadvantaged_pct"] * s["eligible_pupils"]).sum()
|
||||||
|
/ s["eligible_pupils"].sum()
|
||||||
|
)
|
||||||
|
return round(float(w), 1)
|
||||||
|
|
||||||
|
def _block(sub, phase, with_disadvantaged):
|
||||||
|
census = (census_benchmarks or {}).get(phase) or {}
|
||||||
|
block = {
|
||||||
|
"eal_pct": census.get("eal_pct"),
|
||||||
|
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||||
|
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||||
|
"fsm_pct": census.get("fsm_pct"),
|
||||||
|
"median_pupils": census.get("median_pupils"),
|
||||||
|
}
|
||||||
|
if with_disadvantaged:
|
||||||
|
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||||
|
return block
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "state-school average (computed from our dataset)",
|
||||||
|
"year": int(latest_year),
|
||||||
|
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||||
|
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ofsted_block(o, urn: int) -> dict:
|
||||||
|
"""Serialize the latest Ofsted inspection row for API responses.
|
||||||
|
|
||||||
|
`grade_source` records where the effective overall grade came from:
|
||||||
|
a graded (Section 5) inspection, or carried forward from an ungraded
|
||||||
|
(Section 8) outcome — materially different claims a UI must be able
|
||||||
|
to distinguish. `report_card` holds coded+labelled renewed-framework
|
||||||
|
(Nov 2025) area judgements; safeguarding is a separate boolean and
|
||||||
|
never appears among the graded areas.
|
||||||
|
"""
|
||||||
|
if o.overall_effectiveness is not None:
|
||||||
|
grade_source = "graded"
|
||||||
|
overall = o.overall_effectiveness
|
||||||
|
elif o.ungraded_grade is not None:
|
||||||
|
# Fall back to the grade parsed from an ungraded (Section 8) outcome
|
||||||
|
# (e.g. "School remains Good") so the detail page matches the list badge.
|
||||||
|
grade_source = "ungraded_carried_forward"
|
||||||
|
overall = o.ungraded_grade
|
||||||
|
else:
|
||||||
|
grade_source = None
|
||||||
|
overall = None
|
||||||
|
|
||||||
|
block = {
|
||||||
|
"framework": o.framework,
|
||||||
|
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
||||||
|
"rc_inspection_date": (
|
||||||
|
o.rc_inspection_date.isoformat()
|
||||||
|
if getattr(o, "rc_inspection_date", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"inspection_type": o.inspection_type,
|
||||||
|
"overall_effectiveness": overall,
|
||||||
|
"grade_source": grade_source,
|
||||||
|
"quality_of_education": o.quality_of_education,
|
||||||
|
"behaviour_attitudes": o.behaviour_attitudes,
|
||||||
|
"personal_development": o.personal_development,
|
||||||
|
"leadership_management": o.leadership_management,
|
||||||
|
"early_years_provision": o.early_years_provision,
|
||||||
|
"sixth_form_provision": o.sixth_form_provision,
|
||||||
|
"previous_overall": None, # Not available in new schema
|
||||||
|
"rc_safeguarding_met": o.rc_safeguarding_met,
|
||||||
|
"rc_inclusion": o.rc_inclusion,
|
||||||
|
"rc_curriculum_teaching": o.rc_curriculum_teaching,
|
||||||
|
"rc_achievement": o.rc_achievement,
|
||||||
|
"rc_attendance_behaviour": o.rc_attendance_behaviour,
|
||||||
|
"rc_personal_development": o.rc_personal_development,
|
||||||
|
"rc_leadership_governance": o.rc_leadership_governance,
|
||||||
|
"rc_early_years": o.rc_early_years,
|
||||||
|
"rc_sixth_form": o.rc_sixth_form,
|
||||||
|
"report_url": o.report_url,
|
||||||
|
"ofsted_page_url": ofsted_page_url(urn),
|
||||||
|
}
|
||||||
|
block["report_card"] = report_card_labels(block)
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def _admissions_row_dict(a) -> dict:
|
||||||
|
"""Serialize one fact_admissions row for API responses."""
|
||||||
|
return {
|
||||||
|
"year": a.year,
|
||||||
|
"school_phase": a.school_phase,
|
||||||
|
"places_offered": a.places_offered,
|
||||||
|
"total_applications": a.total_applications,
|
||||||
|
"first_preference_applications": a.first_preference_applications,
|
||||||
|
"first_preference_offers": a.first_preference_offers,
|
||||||
|
"first_preference_offer_pct": a.first_preference_offer_pct,
|
||||||
|
"oversubscription_ratio": a.oversubscription_ratio,
|
||||||
|
"oversubscribed": a.oversubscribed,
|
||||||
|
"total_offers": a.total_offers,
|
||||||
|
"second_preference_offers": a.second_preference_offers,
|
||||||
|
"third_preference_offers": a.third_preference_offers,
|
||||||
|
"cross_la_applications": a.cross_la_applications,
|
||||||
|
"cross_la_offers": a.cross_la_offers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _census_dict(pc) -> dict:
|
||||||
|
return {
|
||||||
|
"year": pc.year,
|
||||||
|
"total_pupils": pc.total_pupils,
|
||||||
|
"female_pupils": pc.female_pupils,
|
||||||
|
"male_pupils": pc.male_pupils,
|
||||||
|
"fsm_pct": pc.fsm_pct,
|
||||||
|
"eal_pct": pc.eal_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _deprivation_dict(d) -> dict:
|
||||||
|
return {
|
||||||
|
"lsoa_code": d.lsoa_code,
|
||||||
|
"idaci_score": d.idaci_score,
|
||||||
|
"idaci_decile": d.idaci_decile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finance_dict(f) -> dict:
|
||||||
|
return {
|
||||||
|
"year": f.year,
|
||||||
|
"per_pupil_spend": f.per_pupil_spend,
|
||||||
|
"staff_cost_pct": f.staff_cost_pct,
|
||||||
|
"teacher_cost_pct": f.teacher_cost_pct,
|
||||||
|
"support_staff_cost_pct": f.support_staff_cost_pct,
|
||||||
|
"premises_cost_pct": f.premises_cost_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_supplementary() -> dict:
|
||||||
|
return {
|
||||||
|
"ofsted": None,
|
||||||
|
"census": None,
|
||||||
|
"admissions": None,
|
||||||
|
"admissions_history": [],
|
||||||
|
"sen_detail": None,
|
||||||
|
"phonics": None,
|
||||||
|
"deprivation": None,
|
||||||
|
"finance": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
||||||
|
"""Fetch supplementary data for many URNs with one query per table
|
||||||
|
(WHERE urn IN (...)) instead of ~5 queries per school, collapsing the
|
||||||
|
per-request round-trips from 5*N to a constant 5. Returns {urn: block}
|
||||||
|
with the same shape get_supplementary_data produces per URN.
|
||||||
|
|
||||||
|
Each table is queried independently and failures degrade that table to
|
||||||
|
empty for every URN — a missing mart never blanks the others.
|
||||||
|
"""
|
||||||
|
urns = [int(u) for u in urns]
|
||||||
|
result = {urn: _empty_supplementary() for urn in urns}
|
||||||
|
if not urns:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _safe(fn):
|
||||||
try:
|
try:
|
||||||
q = db.query(model).filter(getattr(model, pk_field) == urn)
|
fn()
|
||||||
if latest_field:
|
|
||||||
q = q.order_by(getattr(model, latest_field).desc())
|
|
||||||
return q.first()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
|
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return None
|
|
||||||
|
|
||||||
# Latest Ofsted inspection
|
# Ofsted — latest inspection per URN. Ordered so the first row seen per
|
||||||
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
|
# URN is the most recent.
|
||||||
result["ofsted"] = (
|
def _ofsted():
|
||||||
{
|
rows = (
|
||||||
"framework": o.framework,
|
db.query(FactOfstedInspection)
|
||||||
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
.filter(FactOfstedInspection.urn.in_(urns))
|
||||||
"inspection_type": o.inspection_type,
|
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
|
||||||
"overall_effectiveness": o.overall_effectiveness,
|
.all()
|
||||||
"quality_of_education": o.quality_of_education,
|
)
|
||||||
"behaviour_attitudes": o.behaviour_attitudes,
|
seen = set()
|
||||||
"personal_development": o.personal_development,
|
for o in rows:
|
||||||
"leadership_management": o.leadership_management,
|
if o.urn in seen:
|
||||||
"early_years_provision": o.early_years_provision,
|
continue
|
||||||
"sixth_form_provision": o.sixth_form_provision,
|
seen.add(o.urn)
|
||||||
"previous_overall": None, # Not available in new schema
|
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
|
||||||
"rc_safeguarding_met": o.rc_safeguarding_met,
|
_safe(_ofsted)
|
||||||
"rc_inclusion": o.rc_inclusion,
|
|
||||||
"rc_curriculum_teaching": o.rc_curriculum_teaching,
|
|
||||||
"rc_achievement": o.rc_achievement,
|
|
||||||
"rc_attendance_behaviour": o.rc_attendance_behaviour,
|
|
||||||
"rc_personal_development": o.rc_personal_development,
|
|
||||||
"rc_leadership_governance": o.rc_leadership_governance,
|
|
||||||
"rc_early_years": o.rc_early_years,
|
|
||||||
"rc_sixth_form": o.rc_sixth_form,
|
|
||||||
"report_url": o.report_url,
|
|
||||||
}
|
|
||||||
if o
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parent View
|
# Census — latest year per URN.
|
||||||
pv = safe_query(FactParentView, "urn")
|
def _census():
|
||||||
result["parent_view"] = (
|
rows = (
|
||||||
{
|
db.query(FactPupilCharacteristics)
|
||||||
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
|
.filter(FactPupilCharacteristics.urn.in_(urns))
|
||||||
"total_responses": pv.total_responses,
|
.order_by(FactPupilCharacteristics.urn, FactPupilCharacteristics.year.desc())
|
||||||
"q_happy_pct": pv.q_happy_pct,
|
.all()
|
||||||
"q_safe_pct": pv.q_safe_pct,
|
)
|
||||||
"q_behaviour_pct": pv.q_behaviour_pct,
|
seen = set()
|
||||||
"q_bullying_pct": pv.q_bullying_pct,
|
for pc in rows:
|
||||||
"q_communication_pct": pv.q_communication_pct,
|
if pc.urn in seen:
|
||||||
"q_progress_pct": pv.q_progress_pct,
|
continue
|
||||||
"q_teaching_pct": pv.q_teaching_pct,
|
seen.add(pc.urn)
|
||||||
"q_information_pct": pv.q_information_pct,
|
result[pc.urn]["census"] = _census_dict(pc)
|
||||||
"q_curriculum_pct": pv.q_curriculum_pct,
|
_safe(_census)
|
||||||
"q_future_pct": pv.q_future_pct,
|
|
||||||
"q_leadership_pct": pv.q_leadership_pct,
|
|
||||||
"q_wellbeing_pct": pv.q_wellbeing_pct,
|
|
||||||
"q_recommend_pct": pv.q_recommend_pct,
|
|
||||||
}
|
|
||||||
if pv
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Census (fact_pupil_characteristics — minimal until census columns are verified)
|
# Admissions — all years per URN, oldest first (multi-year trend view).
|
||||||
result["census"] = None
|
def _admissions():
|
||||||
|
rows = (
|
||||||
|
db.query(FactAdmissions)
|
||||||
|
.filter(FactAdmissions.urn.in_(urns))
|
||||||
|
.order_by(FactAdmissions.urn, FactAdmissions.year.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
history: dict = {urn: [] for urn in urns}
|
||||||
|
for a in rows:
|
||||||
|
history[a.urn].append(_admissions_row_dict(a))
|
||||||
|
for urn, rows_for_urn in history.items():
|
||||||
|
result[urn]["admissions_history"] = rows_for_urn
|
||||||
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
|
_safe(_admissions)
|
||||||
|
|
||||||
# Admissions (latest year)
|
# Deprivation — one row per URN.
|
||||||
a = safe_query(FactAdmissions, "urn", "year")
|
def _deprivation():
|
||||||
result["admissions"] = (
|
rows = (
|
||||||
{
|
db.query(FactDeprivation)
|
||||||
"year": a.year,
|
.filter(FactDeprivation.urn.in_(urns))
|
||||||
"school_phase": a.school_phase,
|
.all()
|
||||||
"published_admission_number": a.published_admission_number,
|
)
|
||||||
"total_applications": a.total_applications,
|
for d in rows:
|
||||||
"first_preference_applications": a.first_preference_applications,
|
result[d.urn]["deprivation"] = _deprivation_dict(d)
|
||||||
"first_preference_offers": a.first_preference_offers,
|
_safe(_deprivation)
|
||||||
"first_preference_offer_pct": a.first_preference_offer_pct,
|
|
||||||
"oversubscription_ratio": a.oversubscription_ratio,
|
|
||||||
"oversubscribed": a.oversubscribed,
|
|
||||||
}
|
|
||||||
if a
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# SEN detail — not available in current marts
|
# Finance — latest year per URN.
|
||||||
result["sen_detail"] = None
|
def _finance():
|
||||||
|
rows = (
|
||||||
# Phonics — no school-level data on EES
|
db.query(FactFinance)
|
||||||
result["phonics"] = None
|
.filter(FactFinance.urn.in_(urns))
|
||||||
|
.order_by(FactFinance.urn, FactFinance.year.desc())
|
||||||
# Deprivation
|
.all()
|
||||||
d = safe_query(FactDeprivation, "urn")
|
)
|
||||||
result["deprivation"] = (
|
seen = set()
|
||||||
{
|
for f in rows:
|
||||||
"lsoa_code": d.lsoa_code,
|
if f.urn in seen:
|
||||||
"idaci_score": d.idaci_score,
|
continue
|
||||||
"idaci_decile": d.idaci_decile,
|
seen.add(f.urn)
|
||||||
}
|
result[f.urn]["finance"] = _finance_dict(f)
|
||||||
if d
|
_safe(_finance)
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Finance (latest year)
|
|
||||||
f = safe_query(FactFinance, "urn", "year")
|
|
||||||
result["finance"] = (
|
|
||||||
{
|
|
||||||
"year": f.year,
|
|
||||||
"per_pupil_spend": f.per_pupil_spend,
|
|
||||||
"staff_cost_pct": f.staff_cost_pct,
|
|
||||||
"teacher_cost_pct": f.teacher_cost_pct,
|
|
||||||
"support_staff_cost_pct": f.support_staff_cost_pct,
|
|
||||||
"premises_cost_pct": f.premises_cost_pct,
|
|
||||||
}
|
|
||||||
if f
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||||
|
"""Supplementary data for a single URN (thin wrapper over the batch)."""
|
||||||
|
return get_supplementary_data_batch(db, [urn])[int(urn)]
|
||||||
|
|||||||
+2
-5
@@ -15,6 +15,7 @@ engine = create_engine(
|
|||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800, # recycle connections every 30 min to avoid stale TCP
|
||||||
echo=False,
|
echo=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,13 +35,9 @@ def get_db():
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db_session():
|
def get_db_session():
|
||||||
"""Context manager for non-FastAPI contexts."""
|
"""Context manager for non-FastAPI contexts (read-only)."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""GIAS code -> name dictionaries.
|
||||||
|
|
||||||
|
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
|
||||||
|
— do not edit by hand; rerun the script when the dbt drift test warns.
|
||||||
|
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
|
||||||
|
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SCHOOL_TYPE: dict[int, str] = {
|
||||||
|
1: "Community school",
|
||||||
|
2: "Voluntary aided school",
|
||||||
|
3: "Voluntary controlled school",
|
||||||
|
5: "Foundation school",
|
||||||
|
6: "City technology college",
|
||||||
|
7: "Community special school",
|
||||||
|
8: "Non-maintained special school",
|
||||||
|
10: "Other independent special school",
|
||||||
|
11: "Other independent school",
|
||||||
|
12: "Foundation special school",
|
||||||
|
14: "Pupil referral unit",
|
||||||
|
15: "Local authority nursery school",
|
||||||
|
18: "Further education",
|
||||||
|
24: "Secure units",
|
||||||
|
25: "Offshore schools",
|
||||||
|
26: "Service children's education",
|
||||||
|
27: "Miscellaneous",
|
||||||
|
28: "Academy sponsor led",
|
||||||
|
29: "Higher education institutions",
|
||||||
|
30: "Welsh establishment",
|
||||||
|
31: "Sixth form centres",
|
||||||
|
32: "Special post 16 institution",
|
||||||
|
33: "Academy special sponsor led",
|
||||||
|
34: "Academy converter",
|
||||||
|
35: "Free schools",
|
||||||
|
36: "Free schools special",
|
||||||
|
37: "British schools overseas",
|
||||||
|
38: "Free schools alternative provision",
|
||||||
|
39: "Free schools 16 to 19",
|
||||||
|
40: "University technical college",
|
||||||
|
41: "Studio schools",
|
||||||
|
42: "Academy alternative provision converter",
|
||||||
|
43: "Academy alternative provision sponsor led",
|
||||||
|
44: "Academy special converter",
|
||||||
|
45: "Academy 16-19 converter",
|
||||||
|
46: "Academy 16 to 19 sponsor led",
|
||||||
|
49: "Online provider",
|
||||||
|
56: "Institution funded by other government department",
|
||||||
|
57: "Academy secure 16 to 19",
|
||||||
|
}
|
||||||
|
|
||||||
|
ESTABLISHMENT_STATUS: dict[int, str] = {
|
||||||
|
1: "Open",
|
||||||
|
2: "Closed",
|
||||||
|
3: "Open, but proposed to close",
|
||||||
|
4: "Proposed to open",
|
||||||
|
}
|
||||||
|
|
||||||
|
PHASE_OF_EDUCATION: dict[int, str] = {
|
||||||
|
0: "Not applicable",
|
||||||
|
1: "Nursery",
|
||||||
|
2: "Primary",
|
||||||
|
3: "Middle deemed primary",
|
||||||
|
4: "Secondary",
|
||||||
|
5: "Middle deemed secondary",
|
||||||
|
6: "16 plus",
|
||||||
|
7: "All-through",
|
||||||
|
}
|
||||||
|
|
||||||
|
OFFICIAL_SIXTH_FORM: dict[int, str] = {
|
||||||
|
0: "Not applicable",
|
||||||
|
1: "Has a sixth form",
|
||||||
|
2: "Does not have a sixth form",
|
||||||
|
9: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
RELIGIOUS_CHARACTER: dict[int, str] = {
|
||||||
|
0: "Does not apply",
|
||||||
|
2: "Church of England",
|
||||||
|
3: "Roman Catholic",
|
||||||
|
4: "Methodist",
|
||||||
|
5: "Jewish",
|
||||||
|
6: "None",
|
||||||
|
7: "Muslim",
|
||||||
|
8: "Seventh Day Adventist",
|
||||||
|
9: "Church of England/Methodist",
|
||||||
|
10: "Methodist/Church of England",
|
||||||
|
11: "Church of England/Roman Catholic",
|
||||||
|
12: "Church of England/United Reformed Church",
|
||||||
|
13: "Roman Catholic/Church of England",
|
||||||
|
14: "Quaker",
|
||||||
|
15: "Christian",
|
||||||
|
16: "United Reformed Church",
|
||||||
|
17: "Congregational Church",
|
||||||
|
18: "Free Church",
|
||||||
|
19: "Church of England/Free Church",
|
||||||
|
20: "Church of England/Christian",
|
||||||
|
21: "Sikh",
|
||||||
|
22: "Greek Orthodox",
|
||||||
|
24: "Buddhist",
|
||||||
|
25: "Hindu",
|
||||||
|
26: "Moravian",
|
||||||
|
28: "Inter- / non- denominational",
|
||||||
|
29: "Multi-faith",
|
||||||
|
30: "Church of England/Methodist/United Reform Church/Baptist",
|
||||||
|
31: "Anglican",
|
||||||
|
32: "Anglican/Christian",
|
||||||
|
33: "Anglican/Evangelical",
|
||||||
|
34: "Anglican/Church of England",
|
||||||
|
35: "Catholic",
|
||||||
|
36: "Charadi Jewish",
|
||||||
|
37: "Christian/Evangelical",
|
||||||
|
38: "Christian Science",
|
||||||
|
39: "Christian/Methodist",
|
||||||
|
40: "Christian/non-denominational",
|
||||||
|
41: "Church of England/Evangelical",
|
||||||
|
42: "Islam",
|
||||||
|
43: "Orthodox Jewish",
|
||||||
|
44: "Plymouth Brethren Christian Church",
|
||||||
|
45: "Protestant",
|
||||||
|
46: "Protestant/Evangelical",
|
||||||
|
47: "Reformed Baptist",
|
||||||
|
48: "Roman Catholic/Anglican",
|
||||||
|
49: "Sunni Deobandi",
|
||||||
|
99: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
ADMISSIONS_POLICY: dict[int, str] = {
|
||||||
|
0: "Not applicable",
|
||||||
|
2: "Selective",
|
||||||
|
4: "Non-selective",
|
||||||
|
9: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate(code, mapping: dict[int, str]) -> str | None:
|
||||||
|
"""Translate a GIAS code to its display name.
|
||||||
|
|
||||||
|
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
|
||||||
|
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
|
||||||
|
"""
|
||||||
|
if code is None or (isinstance(code, float) and math.isnan(code)):
|
||||||
|
return None
|
||||||
|
code = int(code)
|
||||||
|
if code not in mapping:
|
||||||
|
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
|
||||||
|
return f"Unknown ({code})"
|
||||||
|
return mapping[code]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+82
-29
@@ -17,21 +17,22 @@ class DimSchool(Base):
|
|||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
school_name = Column(String(255), nullable=False)
|
school_name = Column(String(255), nullable=False)
|
||||||
phase = Column(String(100))
|
phase_code = Column(Integer)
|
||||||
school_type = Column(String(100))
|
school_type_code = Column(Integer)
|
||||||
academy_trust_name = Column(String(255))
|
academy_trust_name = Column(String(255))
|
||||||
academy_trust_uid = Column(String(20))
|
academy_trust_uid = Column(String(20))
|
||||||
religious_character = Column(String(100))
|
religious_character_code = Column(Integer)
|
||||||
gender = Column(String(20))
|
gender = Column(String(20))
|
||||||
age_range = Column(String(20))
|
age_range = Column(String(20))
|
||||||
|
has_sixth_form = Column(Boolean)
|
||||||
capacity = Column(Integer)
|
capacity = Column(Integer)
|
||||||
total_pupils = Column(Integer)
|
total_pupils = Column(Integer)
|
||||||
headteacher_name = Column(String(200))
|
headteacher_name = Column(String(200))
|
||||||
website = Column(String(255))
|
website = Column(String(255))
|
||||||
telephone = Column(String(30))
|
telephone = Column(String(30))
|
||||||
status = Column(String(50))
|
status_code = Column(Integer)
|
||||||
nursery_provision = Column(Boolean)
|
nursery_provision = Column(Boolean)
|
||||||
admissions_policy = Column(String(50))
|
admissions_policy_code = Column(Integer)
|
||||||
# Denormalised Ofsted summary (updated by monthly pipeline)
|
# Denormalised Ofsted summary (updated by monthly pipeline)
|
||||||
ofsted_grade = Column(Integer)
|
ofsted_grade = Column(Integer)
|
||||||
ofsted_date = Column(Date)
|
ofsted_date = Column(Date)
|
||||||
@@ -87,6 +88,15 @@ class KS2Performance(Base):
|
|||||||
maths_high_pct = Column(Float)
|
maths_high_pct = Column(Float)
|
||||||
maths_avg_score = Column(Float)
|
maths_avg_score = Column(Float)
|
||||||
maths_progress = Column(Float)
|
maths_progress = Column(Float)
|
||||||
|
# Progress confidence intervals + writing working-towards (published
|
||||||
|
# for years with progress measures, i.e. up to 2022/23)
|
||||||
|
reading_progress_lower_ci = Column(Float)
|
||||||
|
reading_progress_upper_ci = Column(Float)
|
||||||
|
writing_progress_lower_ci = Column(Float)
|
||||||
|
writing_progress_upper_ci = Column(Float)
|
||||||
|
writing_working_towards_pct = Column(Float)
|
||||||
|
maths_progress_lower_ci = Column(Float)
|
||||||
|
maths_progress_upper_ci = Column(Float)
|
||||||
gps_expected_pct = Column(Float)
|
gps_expected_pct = Column(Float)
|
||||||
gps_high_pct = Column(Float)
|
gps_high_pct = Column(Float)
|
||||||
gps_avg_score = Column(Float)
|
gps_avg_score = Column(Float)
|
||||||
@@ -133,6 +143,10 @@ class FactOfstedInspection(Base):
|
|||||||
leadership_management = Column(Integer)
|
leadership_management = Column(Integer)
|
||||||
early_years_provision = Column(Integer)
|
early_years_provision = Column(Integer)
|
||||||
sixth_form_provision = Column(Integer)
|
sixth_form_provision = Column(Integer)
|
||||||
|
# Ungraded (Section 8) inspection: raw outcome text and the grade parsed from
|
||||||
|
# it (fallback for schools with no graded overall effectiveness).
|
||||||
|
ungraded_outcome = Column(String(100))
|
||||||
|
ungraded_grade = Column(Integer)
|
||||||
rc_safeguarding_met = Column(Boolean)
|
rc_safeguarding_met = Column(Boolean)
|
||||||
rc_inclusion = Column(Integer)
|
rc_inclusion = Column(Integer)
|
||||||
rc_curriculum_teaching = Column(Integer)
|
rc_curriculum_teaching = Column(Integer)
|
||||||
@@ -142,32 +156,12 @@ class FactOfstedInspection(Base):
|
|||||||
rc_leadership_governance = Column(Integer)
|
rc_leadership_governance = Column(Integer)
|
||||||
rc_early_years = Column(Integer)
|
rc_early_years = Column(Integer)
|
||||||
rc_sixth_form = Column(Integer)
|
rc_sixth_form = Column(Integer)
|
||||||
|
# Start date of the report-card inspection itself (renewed framework,
|
||||||
|
# Nov 2025+). Null for rows without report-card grades.
|
||||||
|
rc_inspection_date = Column(Date)
|
||||||
report_url = Column(Text)
|
report_url = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
class FactParentView(Base):
|
|
||||||
"""Ofsted Parent View survey — latest per school."""
|
|
||||||
__tablename__ = "fact_parent_view"
|
|
||||||
__table_args__ = MARTS
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
|
||||||
survey_date = Column(Date)
|
|
||||||
total_responses = Column(Integer)
|
|
||||||
q_happy_pct = Column(Float)
|
|
||||||
q_safe_pct = Column(Float)
|
|
||||||
q_behaviour_pct = Column(Float)
|
|
||||||
q_bullying_pct = Column(Float)
|
|
||||||
q_communication_pct = Column(Float)
|
|
||||||
q_progress_pct = Column(Float)
|
|
||||||
q_teaching_pct = Column(Float)
|
|
||||||
q_information_pct = Column(Float)
|
|
||||||
q_curriculum_pct = Column(Float)
|
|
||||||
q_future_pct = Column(Float)
|
|
||||||
q_leadership_pct = Column(Float)
|
|
||||||
q_wellbeing_pct = Column(Float)
|
|
||||||
q_recommend_pct = Column(Float)
|
|
||||||
|
|
||||||
|
|
||||||
class FactAdmissions(Base):
|
class FactAdmissions(Base):
|
||||||
"""School admissions — one row per URN per year."""
|
"""School admissions — one row per URN per year."""
|
||||||
__tablename__ = "fact_admissions"
|
__tablename__ = "fact_admissions"
|
||||||
@@ -179,16 +173,39 @@ class FactAdmissions(Base):
|
|||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
school_phase = Column(String(50))
|
school_phase = Column(String(50))
|
||||||
published_admission_number = Column(Integer)
|
places_offered = Column(Integer)
|
||||||
total_applications = Column(Integer)
|
total_applications = Column(Integer)
|
||||||
first_preference_applications = Column(Integer)
|
first_preference_applications = Column(Integer)
|
||||||
first_preference_offers = Column(Integer)
|
first_preference_offers = Column(Integer)
|
||||||
|
total_offers = Column(Integer)
|
||||||
|
second_preference_offers = Column(Integer)
|
||||||
|
third_preference_offers = Column(Integer)
|
||||||
|
cross_la_applications = Column(Integer)
|
||||||
|
cross_la_offers = Column(Integer)
|
||||||
first_preference_offer_pct = Column(Float)
|
first_preference_offer_pct = Column(Float)
|
||||||
oversubscription_ratio = Column(Float)
|
oversubscription_ratio = Column(Float)
|
||||||
oversubscribed = Column(Boolean)
|
oversubscribed = Column(Boolean)
|
||||||
admissions_policy = Column(String(100))
|
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__ = (
|
||||||
|
Index("ix_pupil_chars_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
phase_type_grouping = Column(String(50))
|
||||||
|
total_pupils = Column(Integer)
|
||||||
|
female_pupils = Column(Integer)
|
||||||
|
male_pupils = Column(Integer)
|
||||||
|
fsm_pct = Column(Float)
|
||||||
|
eal_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
class FactDeprivation(Base):
|
class FactDeprivation(Base):
|
||||||
"""IDACI deprivation index — one row per URN."""
|
"""IDACI deprivation index — one row per URN."""
|
||||||
__tablename__ = "fact_deprivation"
|
__tablename__ = "fact_deprivation"
|
||||||
@@ -217,6 +234,42 @@ class FactFinance(Base):
|
|||||||
premises_cost_pct = Column(Float)
|
premises_cost_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class CensusBenchmark(Base):
|
||||||
|
"""State-school context benchmarks from the pupil census — one row per phase.
|
||||||
|
|
||||||
|
fsm_pct / eal_pct are pupil-weighted means. Computed at import time;
|
||||||
|
consumers label them "state-school average (computed from our dataset)".
|
||||||
|
"""
|
||||||
|
__tablename__ = "fact_census_benchmarks"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
phase = Column(String(20), primary_key=True)
|
||||||
|
year = Column(Integer)
|
||||||
|
fsm_pct = Column(Float)
|
||||||
|
eal_pct = Column(Float)
|
||||||
|
median_pupils = Column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class Ks4NationalAverage(Base):
|
||||||
|
"""Official DfE KS4 national headline averages — one row per academic year.
|
||||||
|
|
||||||
|
gcse_grade_91_pct has no official national series and is always NULL.
|
||||||
|
"""
|
||||||
|
__tablename__ = "fact_ks4_national_averages"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
attainment_8_score = Column(Float)
|
||||||
|
progress_8_score = Column(Float)
|
||||||
|
english_maths_standard_pass_pct = Column(Float)
|
||||||
|
english_maths_strong_pass_pct = Column(Float)
|
||||||
|
ebacc_entry_pct = Column(Float)
|
||||||
|
ebacc_standard_pass_pct = Column(Float)
|
||||||
|
ebacc_strong_pass_pct = Column(Float)
|
||||||
|
ebacc_avg_score = Column(Float)
|
||||||
|
gcse_grade_91_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
class Ks2NationalAverage(Base):
|
class Ks2NationalAverage(Base):
|
||||||
"""Official DfE KS2 national headline averages — one row per academic year."""
|
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||||
__tablename__ = "fact_ks2_national_averages"
|
__tablename__ = "fact_ks2_national_averages"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Ofsted renewed-framework (Nov 2025) report-card code translation.
|
||||||
|
|
||||||
|
Scale labels are the live-sampled vocabulary from the Ofsted MI file
|
||||||
|
(see pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE) —
|
||||||
|
verified against real data, not the consultation draft.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REPORT_CARD_GRADE_NAMES = {
|
||||||
|
1: "Exceptional",
|
||||||
|
2: "Strong standard",
|
||||||
|
3: "Expected standard",
|
||||||
|
4: "Needs attention",
|
||||||
|
5: "Urgent improvement",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Graded evaluation areas only — safeguarding is a separate boolean
|
||||||
|
# judgement and must never appear in grade counts or label maps.
|
||||||
|
_RC_AREA_KEYS = (
|
||||||
|
"rc_inclusion",
|
||||||
|
"rc_curriculum_teaching",
|
||||||
|
"rc_achievement",
|
||||||
|
"rc_attendance_behaviour",
|
||||||
|
"rc_personal_development",
|
||||||
|
"rc_leadership_governance",
|
||||||
|
"rc_early_years",
|
||||||
|
"rc_sixth_form",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def report_card_labels(ofsted: dict) -> dict:
|
||||||
|
"""{area_key: {code, label}} for populated, known-valued rc_* areas."""
|
||||||
|
out = {}
|
||||||
|
for key in _RC_AREA_KEYS:
|
||||||
|
code = ofsted.get(key)
|
||||||
|
label = REPORT_CARD_GRADE_NAMES.get(code)
|
||||||
|
if code is not None and label is not None:
|
||||||
|
out[key] = {"code": code, "label": label}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ofsted_page_url(urn: int) -> str:
|
||||||
|
"""The school's page on ofsted.gov.uk (all its reports live there —
|
||||||
|
we never deep-link an individual report)."""
|
||||||
|
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
|
||||||
@@ -543,6 +543,8 @@ SCHOOL_COLUMNS = [
|
|||||||
"postcode",
|
"postcode",
|
||||||
"religious_denomination",
|
"religious_denomination",
|
||||||
"age_range",
|
"age_range",
|
||||||
|
"has_sixth_form",
|
||||||
|
"status",
|
||||||
"gender",
|
"gender",
|
||||||
"admissions_policy",
|
"admissions_policy",
|
||||||
"ofsted_grade",
|
"ofsted_grade",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""compute_benchmarks: state-school benchmarks computed from our dataset
|
||||||
|
(spec §5/§8.6). The disadvantaged average must be weighted by cohort size,
|
||||||
|
medians must ignore NaN, and only the latest year counts."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backend.data_loader import compute_benchmarks
|
||||||
|
|
||||||
|
LATEST = 202425
|
||||||
|
|
||||||
|
|
||||||
|
def _df():
|
||||||
|
rows = [
|
||||||
|
# Six primary schools, latest year. Disadvantaged RWM chosen so the
|
||||||
|
# weighted average differs clearly from the unweighted mean:
|
||||||
|
# weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100,
|
||||||
|
rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0,
|
||||||
|
sen_support_pct=10.0, disadvantaged_pct=20.0, fsm_pct=15.0, total_pupils=200),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300,
|
||||||
|
rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0,
|
||||||
|
sen_support_pct=14.0, disadvantaged_pct=24.0, fsm_pct=17.0, total_pupils=280),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan,
|
||||||
|
rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0,
|
||||||
|
sen_support_pct=18.0, disadvantaged_pct=30.0, fsm_pct=19.0, total_pupils=300),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50,
|
||||||
|
rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan,
|
||||||
|
sen_support_pct=np.nan, disadvantaged_pct=np.nan, fsm_pct=np.nan, total_pupils=np.nan),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40,
|
||||||
|
rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0,
|
||||||
|
sen_support_pct=20.0, disadvantaged_pct=40.0, fsm_pct=21.0, total_pupils=350),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60,
|
||||||
|
rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0,
|
||||||
|
sen_support_pct=22.0, disadvantaged_pct=44.0, fsm_pct=23.0, total_pupils=400),
|
||||||
|
# Two secondary schools (attainment_8 non-null)
|
||||||
|
dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180,
|
||||||
|
rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0,
|
||||||
|
sen_support_pct=12.0, disadvantaged_pct=22.0, fsm_pct=12.0, total_pupils=1000),
|
||||||
|
dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200,
|
||||||
|
rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0,
|
||||||
|
sen_support_pct=16.0, disadvantaged_pct=26.0, fsm_pct=14.0, total_pupils=1200),
|
||||||
|
# An older-year primary row that must NOT influence anything
|
||||||
|
dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500,
|
||||||
|
rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0,
|
||||||
|
sen_support_pct=99.0, disadvantaged_pct=99.0, fsm_pct=99.0, total_pupils=9999),
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weighted_disadvantaged_average():
|
||||||
|
b = compute_benchmarks(_df())
|
||||||
|
# Row 3 has NaN eligible_pupils and must be excluded from the weighting.
|
||||||
|
assert b["primary"]["disadvantaged_rwm_expected_pct"] == 55.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_medians_ignore_nan_and_older_years():
|
||||||
|
b = compute_benchmarks(_df())
|
||||||
|
assert b["year"] == LATEST
|
||||||
|
# sen medians over [10,14,18,20,22] = 18 — the only context measure still
|
||||||
|
# sourced from the performance df (the rest come from the census mart).
|
||||||
|
assert b["primary"]["sen_support_pct"] == 18.0
|
||||||
|
# disadvantaged_pct medians over [20,24,30,40,44] = 30
|
||||||
|
assert b["primary"]["disadvantaged_pct"] == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmarks_use_census_mart_for_context():
|
||||||
|
census = {
|
||||||
|
"primary": {"year": LATEST, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||||
|
"secondary": {"year": LATEST, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||||
|
}
|
||||||
|
b = compute_benchmarks(_df(), census_benchmarks=census)
|
||||||
|
assert b["primary"]["fsm_pct"] == 25.3
|
||||||
|
assert b["primary"]["eal_pct"] == 21.8
|
||||||
|
assert b["secondary"]["eal_pct"] == 18.9
|
||||||
|
assert b["secondary"]["median_pupils"] == 980
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmarks_context_none_when_mart_missing():
|
||||||
|
# The performance df has no fsm_pct and its eal/disadvantaged columns are
|
||||||
|
# KS2-only — never silently fall back to medianing them for context.
|
||||||
|
b = compute_benchmarks(_df(), census_benchmarks=None)
|
||||||
|
assert b["primary"]["fsm_pct"] is None
|
||||||
|
assert b["primary"]["eal_pct"] is None
|
||||||
|
assert b["primary"]["median_pupils"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_secondary_block_has_no_disadvantaged_rwm():
|
||||||
|
b = compute_benchmarks(_df())
|
||||||
|
assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
|
||||||
|
# KS2-only columns must not produce a fake secondary disadvantaged anchor
|
||||||
|
# (the old median over all-through schools' KS2 rows produced 50%).
|
||||||
|
assert b["secondary"]["disadvantaged_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_string():
|
||||||
|
b = compute_benchmarks(_df())
|
||||||
|
assert b["source"] == "state-school average (computed from our dataset)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_df():
|
||||||
|
assert compute_benchmarks(pd.DataFrame()) == {}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""/api/compare enrichment for the compare redesign: per-school
|
||||||
|
supplementary blocks, top-level national_averages (shared with the
|
||||||
|
/api/national-averages endpoint) and computed benchmarks — all additive."""
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
LATEST = 202425
|
||||||
|
|
||||||
|
CANNED_SUPPLEMENTARY = {
|
||||||
|
"ofsted": {"overall_effectiveness": 2, "grade_source": "graded",
|
||||||
|
"report_card": {}, "ofsted_page_url": "https://reports.ofsted.gov.uk/provider/21/100140"},
|
||||||
|
"census": {"year": 202526, "fsm_pct": 29.8},
|
||||||
|
"admissions": {"year": 202627, "second_preference_offers": 4},
|
||||||
|
"admissions_history": [{"year": 202627, "second_preference_offers": 4}],
|
||||||
|
"sen_detail": None,
|
||||||
|
"phonics": None,
|
||||||
|
"deprivation": {"idaci_decile": 4},
|
||||||
|
"finance": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _two_primary_schools_df() -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
for urn, name, rwm, dis in ((100140, "Plumcroft Primary School", 79.0, 72.0),
|
||||||
|
(138690, "Barclay Primary School", 87.0, 86.0)):
|
||||||
|
rows.append(dict(
|
||||||
|
urn=urn, school_name=name, local_authority="Greenwich",
|
||||||
|
school_type="Community school", address="1 Road", phase="Primary",
|
||||||
|
year=LATEST, rwm_expected_pct=rwm, attainment_8_score=np.nan,
|
||||||
|
eligible_pupils=60, rwm_expected_disadvantaged_pct=dis,
|
||||||
|
eal_pct=20.0, sen_support_pct=14.0, disadvantaged_pct=25.0,
|
||||||
|
total_pupils=1000.0,
|
||||||
|
))
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubNatRow:
|
||||||
|
year = 202425
|
||||||
|
rwm_expected_pct = 62.1
|
||||||
|
gps_expected_pct = 72.0
|
||||||
|
science_expected_pct = 81.0
|
||||||
|
|
||||||
|
|
||||||
|
class _StubSession:
|
||||||
|
def query(self, *a, **k):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *a, **k):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return [_StubNatRow()]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
from backend import database as database_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app_module,
|
||||||
|
"get_supplementary_data_batch",
|
||||||
|
lambda db, urns: {int(u): dict(CANNED_SUPPLEMENTARY) for u in urns},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_shape_is_preserved(client):
|
||||||
|
body = client.get("/api/compare?urns=100140,138690").json()
|
||||||
|
school = body["comparison"]["100140"]
|
||||||
|
assert school["school_info"]["rwm_expected_pct"] == 79.0
|
||||||
|
assert school["yearly_data"][0]["year"] == LATEST
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_school_gains_supplementary_blocks(client):
|
||||||
|
body = client.get("/api/compare?urns=100140,138690").json()
|
||||||
|
for urn in ("100140", "138690"):
|
||||||
|
school = body["comparison"][urn]
|
||||||
|
assert school["ofsted"]["grade_source"] == "graded"
|
||||||
|
assert school["census"]["fsm_pct"] == 29.8
|
||||||
|
assert school["admissions"]["second_preference_offers"] == 4
|
||||||
|
assert school["admissions_history"][0]["year"] == 202627
|
||||||
|
assert school["deprivation"]["idaci_decile"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_level_national_averages_and_benchmarks(client):
|
||||||
|
body = client.get("/api/compare?urns=100140,138690").json()
|
||||||
|
assert body["national_averages"]["year"] == LATEST
|
||||||
|
assert body["benchmarks"]["source"] == "state-school average (computed from our dataset)"
|
||||||
|
# weighted over equal cohorts of 72 and 86 = 79.0
|
||||||
|
assert body["benchmarks"]["primary"]["disadvantaged_rwm_expected_pct"] == 79.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
def _boom(db, urns):
|
||||||
|
raise RuntimeError("marts unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data_batch", _boom)
|
||||||
|
resp = client.get("/api/compare?urns=100140")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
school = resp.json()["comparison"]["100140"]
|
||||||
|
assert school["ofsted"] is None
|
||||||
|
assert school["admissions_history"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_national_averages_endpoint_exposes_gps_science(client):
|
||||||
|
body = client.get("/api/national-averages").json()
|
||||||
|
latest_primary_by_year = [e["primary"] for e in body["by_year"] if e["primary"]]
|
||||||
|
assert latest_primary_by_year, "expected official by_year rows from the stub"
|
||||||
|
assert latest_primary_by_year[-1]["gps_expected_pct"] == 72.0
|
||||||
|
assert latest_primary_by_year[-1]["science_expected_pct"] == 81.0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
|
||||||
|
|
||||||
|
The dictionaries are generated from the live GIAS bulk CSV by
|
||||||
|
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
|
||||||
|
contract, key sentinel values the marts/UI depend on, and that the pipeline
|
||||||
|
copy has not drifted from the canonical backend module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.gias_codes import (
|
||||||
|
ADMISSIONS_POLICY,
|
||||||
|
ESTABLISHMENT_STATUS,
|
||||||
|
OFFICIAL_SIXTH_FORM,
|
||||||
|
PHASE_OF_EDUCATION,
|
||||||
|
RELIGIOUS_CHARACTER,
|
||||||
|
SCHOOL_TYPE,
|
||||||
|
translate,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_known_code():
|
||||||
|
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
|
||||||
|
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_unknown_code_degrades_gracefully():
|
||||||
|
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_none_and_nan_return_none():
|
||||||
|
assert translate(None, ESTABLISHMENT_STATUS) is None
|
||||||
|
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_accepts_float_codes():
|
||||||
|
# pd.read_sql yields float columns when NULLs are present
|
||||||
|
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
|
||||||
|
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentinel_names_present():
|
||||||
|
"""Names the marts/UI compare against must exist verbatim."""
|
||||||
|
assert "Open" in ESTABLISHMENT_STATUS.values()
|
||||||
|
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
|
||||||
|
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
|
||||||
|
assert "Primary" in PHASE_OF_EDUCATION.values()
|
||||||
|
assert "Secondary" in PHASE_OF_EDUCATION.values()
|
||||||
|
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
|
||||||
|
assert all(len(d) > 0 for d in (
|
||||||
|
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
|
||||||
|
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_copy_is_identical():
|
||||||
|
canonical = (REPO / "backend" / "gias_codes.py").read_text()
|
||||||
|
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
|
||||||
|
assert canonical == copy, (
|
||||||
|
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
|
||||||
|
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_matches_dictionaries():
|
||||||
|
import csv
|
||||||
|
fields = {
|
||||||
|
"school_type": SCHOOL_TYPE,
|
||||||
|
"establishment_status": ESTABLISHMENT_STATUS,
|
||||||
|
"phase_of_education": PHASE_OF_EDUCATION,
|
||||||
|
"official_sixth_form": OFFICIAL_SIXTH_FORM,
|
||||||
|
"religious_character": RELIGIOUS_CHARACTER,
|
||||||
|
"admissions_policy": ADMISSIONS_POLICY,
|
||||||
|
}
|
||||||
|
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
|
||||||
|
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
|
||||||
|
with open(seed_path, newline="") as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
seed[row["field"]][int(row["code"])] = row["name"]
|
||||||
|
assert seed == fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_name_sentinel_codes_map_to_empty_string():
|
||||||
|
"""GIAS carries codes whose (name) column is blank — e.g. ReligiousCharacter
|
||||||
|
99 (~4k schools) and AdmissionsPolicy 9 (~5.6k schools). The old name
|
||||||
|
pipeline served these as empty strings; the dictionaries must reproduce
|
||||||
|
that ("" is falsy, so UI tag heuristics stay silent) rather than letting
|
||||||
|
them hit the "Unknown (<code>)" path meant for genuinely new codes."""
|
||||||
|
assert RELIGIOUS_CHARACTER[99] == ""
|
||||||
|
assert ADMISSIONS_POLICY[9] == ""
|
||||||
|
assert translate(99, RELIGIOUS_CHARACTER) == ""
|
||||||
|
assert translate(9, ADMISSIONS_POLICY) == ""
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
|
||||||
|
rest of the backend sees must carry today's name strings."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backend.data_loader import _missing_column_name, translate_gias_code_columns
|
||||||
|
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
|
||||||
|
|
||||||
|
|
||||||
|
def _code_for(mapping, name):
|
||||||
|
return next(c for c, n in mapping.items() if n == name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codes_become_todays_names():
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
"urn": 1,
|
||||||
|
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
|
||||||
|
"school_type_code": np.nan,
|
||||||
|
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
|
||||||
|
"religious_character_code": np.nan,
|
||||||
|
"admissions_policy_code": np.nan,
|
||||||
|
}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
row = out.iloc[0]
|
||||||
|
assert row["phase"] == "Primary"
|
||||||
|
assert row["status"] == "Open, but proposed to close"
|
||||||
|
assert row["school_type"] is None
|
||||||
|
assert row["religious_denomination"] is None
|
||||||
|
assert row["admissions_policy"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_code_degrades_not_blanks():
|
||||||
|
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
assert out.iloc[0]["phase"] == "Unknown (9999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_code_columns_are_a_noop():
|
||||||
|
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
|
||||||
|
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
assert out.iloc[0]["phase"] == "Primary"
|
||||||
|
assert out.iloc[0]["status"] == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_exc(orig_message):
|
||||||
|
"""A stand-in for sqlalchemy.exc.ProgrammingError: str(exc) embeds the
|
||||||
|
full SQL statement (deliberately containing every column name below, to
|
||||||
|
prove the matcher doesn't fall back to it), while .orig carries the real
|
||||||
|
DBAPI error message naming only the offending column."""
|
||||||
|
exc = Exception(
|
||||||
|
"SELECT s.phase_code, s.school_type_code, s.religious_character_code, "
|
||||||
|
"s.status_code, s.admissions_policy_code, s.has_sixth_form FROM ... "
|
||||||
|
f"[SQL: ...] (Background on this error at: https://...)"
|
||||||
|
)
|
||||||
|
exc.orig = Exception(orig_message) if orig_message is not None else None
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_quoted():
|
||||||
|
assert _missing_column_name(_fake_exc('column "phase_code" does not exist')) == "phase_code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_unquoted():
|
||||||
|
assert _missing_column_name(_fake_exc("column phase_code does not exist")) == "phase_code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_table_prefixed():
|
||||||
|
assert (
|
||||||
|
_missing_column_name(_fake_exc("column s.has_sixth_form does not exist"))
|
||||||
|
== "has_sixth_form"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_no_match_returns_none():
|
||||||
|
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_school_data_survives_premigration_marts(monkeypatch):
|
||||||
|
"""Real prod state until the nightly pipeline first rebuilds the mart with
|
||||||
|
the GIAS code columns: marts.dim_school still has the old name columns
|
||||||
|
(phase, school_type, religious_character, status, admissions_policy)
|
||||||
|
instead of the new *_code columns. The first query raises UndefinedColumn
|
||||||
|
on s.phase_code; load_school_data_as_dataframe must retry with the
|
||||||
|
legacy name-column query rather than swallow the error and return (and
|
||||||
|
then have load_school_data cache) an empty DataFrame."""
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from backend import data_loader
|
||||||
|
|
||||||
|
data_loader._df_cache = None
|
||||||
|
data_loader._df_latest_cache = None
|
||||||
|
|
||||||
|
good_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"urn": 1,
|
||||||
|
"school_name": "Legacy School",
|
||||||
|
"phase": "Primary",
|
||||||
|
"school_type": "Academy",
|
||||||
|
"status": "Open",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_read_sql(query, con):
|
||||||
|
calls.append(query)
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise sqlalchemy.exc.ProgrammingError(
|
||||||
|
statement=str(data_loader._MAIN_QUERY),
|
||||||
|
params=None,
|
||||||
|
orig=Exception(
|
||||||
|
"(psycopg2.errors.UndefinedColumn) column s.phase_code "
|
||||||
|
"does not exist\nLINE 5: s.phase_code,"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return good_df.copy()
|
||||||
|
|
||||||
|
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = data_loader.load_school_data_as_dataframe()
|
||||||
|
finally:
|
||||||
|
data_loader._df_cache = None
|
||||||
|
data_loader._df_latest_cache = None
|
||||||
|
|
||||||
|
assert len(calls) == 2, "must retry with the legacy name-column query variant"
|
||||||
|
assert calls[1] is data_loader._MAIN_QUERY_LEGACY_NAMES
|
||||||
|
assert not df.empty
|
||||||
|
assert df["phase"].iloc[0] == "Primary"
|
||||||
|
assert df["status"].iloc[0] == "Open"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""_national_averages_payload reads persisted marts (computed at import
|
||||||
|
time) — it must never aggregate the dataframe. Both marts hold OFFICIAL
|
||||||
|
DfE figures, so a missing KS4 mart yields an empty secondary series —
|
||||||
|
never a computed stand-in the UI would mislabel as official."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
LATEST = 202425
|
||||||
|
|
||||||
|
|
||||||
|
def _df():
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
dict(year=202324, attainment_8_score=40.0, rwm_expected_pct=np.nan),
|
||||||
|
dict(year=LATEST, attainment_8_score=50.0, rwm_expected_pct=np.nan),
|
||||||
|
dict(year=LATEST, attainment_8_score=30.0, rwm_expected_pct=np.nan),
|
||||||
|
dict(year=LATEST, attainment_8_score=np.nan, rwm_expected_pct=80.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Ks2Row:
|
||||||
|
year = LATEST
|
||||||
|
rwm_expected_pct = 62.1
|
||||||
|
gps_expected_pct = 72.0
|
||||||
|
|
||||||
|
|
||||||
|
class _Ks4Row:
|
||||||
|
year = LATEST
|
||||||
|
attainment_8_score = 46.5
|
||||||
|
progress_8_score = -0.02
|
||||||
|
|
||||||
|
|
||||||
|
class _StubSession:
|
||||||
|
"""Returns KS2 rows for the first query and KS4 rows for the second —
|
||||||
|
mirroring the payload's query order."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
self._model = model.__name__
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *a):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return [_Ks2Row()] if self._model == "Ks2NationalAverage" else [_Ks4Row()]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Ks4MissingSession(_StubSession):
|
||||||
|
def all(self):
|
||||||
|
if self._model == "Ks4NationalAverage":
|
||||||
|
raise RuntimeError("relation does not exist")
|
||||||
|
return [_Ks2Row()]
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def payload(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
from backend import database as database_module
|
||||||
|
|
||||||
|
def _run(session_cls):
|
||||||
|
monkeypatch.setattr(database_module, "SessionLocal", session_cls)
|
||||||
|
return app_module._national_averages_payload(_df())
|
||||||
|
|
||||||
|
return _run
|
||||||
|
|
||||||
|
|
||||||
|
def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
||||||
|
body = payload(_StubSession)
|
||||||
|
# Mart value (46.5), NOT the dataframe mean of (50+30)/2 = 40.0
|
||||||
|
assert body["secondary"]["attainment_8_score"] == 46.5
|
||||||
|
assert body["primary"]["rwm_expected_pct"] == 62.1
|
||||||
|
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
||||||
|
|
||||||
|
|
||||||
|
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||||
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
|
body = payload(_Ks4MissingSession)
|
||||||
|
assert body["secondary"] == {}
|
||||||
|
assert all(not e["secondary"] for e in body["by_year"])
|
||||||
|
# The KS2 series is unaffected.
|
||||||
|
assert body["primary"]["rwm_expected_pct"] == 62.1
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Report-card code translation uses the live-sampled Ofsted vocabulary
|
||||||
|
(pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE):
|
||||||
|
Exceptional / Strong standard / Expected standard / Needs attention /
|
||||||
|
Urgent improvement — never the consultation draft's 'Attention needed'."""
|
||||||
|
|
||||||
|
from backend.ofsted_codes import (
|
||||||
|
REPORT_CARD_GRADE_NAMES,
|
||||||
|
ofsted_page_url,
|
||||||
|
report_card_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_is_sampled_vocabulary():
|
||||||
|
assert REPORT_CARD_GRADE_NAMES == {
|
||||||
|
1: "Exceptional",
|
||||||
|
2: "Strong standard",
|
||||||
|
3: "Expected standard",
|
||||||
|
4: "Needs attention",
|
||||||
|
5: "Urgent improvement",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_only_for_populated_areas_and_never_safeguarding():
|
||||||
|
ofsted = {
|
||||||
|
"rc_achievement": 2,
|
||||||
|
"rc_inclusion": 3,
|
||||||
|
"rc_attendance_behaviour": 4,
|
||||||
|
"rc_early_years": None,
|
||||||
|
"rc_safeguarding_met": True,
|
||||||
|
"overall_effectiveness": None,
|
||||||
|
}
|
||||||
|
labels = report_card_labels(ofsted)
|
||||||
|
assert labels == {
|
||||||
|
"rc_achievement": {"code": 2, "label": "Strong standard"},
|
||||||
|
"rc_inclusion": {"code": 3, "label": "Expected standard"},
|
||||||
|
"rc_attendance_behaviour": {"code": 4, "label": "Needs attention"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_code_is_skipped_not_crashed():
|
||||||
|
assert report_card_labels({"rc_achievement": 9}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_url():
|
||||||
|
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Regression tests for GET /api/schools/{urn}.
|
||||||
|
|
||||||
|
Schools with no performance rows (special post-16 institutions, sixth-form
|
||||||
|
centres, PRUs, brand-new schools) come back from the marts LEFT JOIN with
|
||||||
|
NaN in every numeric column. The endpoint must still serialize them — a NaN
|
||||||
|
that reaches Starlette's JSONResponse raises ValueError (allow_nan=False)
|
||||||
|
and the route 500s, which the frontend then renders as a 404.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _no_results_school_df() -> pd.DataFrame:
|
||||||
|
"""One school row as produced by the marts query for a school with no
|
||||||
|
performance data: GIAS/location fields partly populated, every
|
||||||
|
results-linked column NaN (including year)."""
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"urn": 150275,
|
||||||
|
"school_name": "West London Performing Arts Academy",
|
||||||
|
"phase": "Secondary",
|
||||||
|
"school_type": "Special post 16 institution",
|
||||||
|
"trust_name": None,
|
||||||
|
"religious_denomination": "Does not apply",
|
||||||
|
"gender": None,
|
||||||
|
"age_range": "16-25",
|
||||||
|
"admissions_policy": None,
|
||||||
|
"capacity": np.nan,
|
||||||
|
"gias_total_pupils": np.nan,
|
||||||
|
"headteacher_name": None,
|
||||||
|
"website": None,
|
||||||
|
"ofsted_grade": np.nan,
|
||||||
|
"local_authority": "Ealing",
|
||||||
|
"address": "268 Northfield Avenue, London, W5 4UB",
|
||||||
|
"postcode": "W5 4UB",
|
||||||
|
"latitude": 51.4986,
|
||||||
|
"longitude": -0.3148,
|
||||||
|
"year": np.nan,
|
||||||
|
"total_pupils": np.nan,
|
||||||
|
"eligible_pupils": np.nan,
|
||||||
|
"rwm_expected_pct": np.nan,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _no_results_school_df)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app_module, "get_supplementary_data", lambda db, urn: {}
|
||||||
|
)
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_school_without_performance_rows_returns_200(client):
|
||||||
|
resp = client.get("/api/schools/150275")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nan_gias_fields_serialize_as_null(client):
|
||||||
|
info = client.get("/api/schools/150275").json()["school_info"]
|
||||||
|
assert info["capacity"] is None
|
||||||
|
assert info["total_pupils"] is None
|
||||||
|
assert info["school_name"] == "West London Performing Arts Academy"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tests for GIAS establishment status exposure.
|
||||||
|
|
||||||
|
"Open, but proposed to close" schools are now kept by the dims; the API must
|
||||||
|
surface `status` on list items and school_info so the UI can render the
|
||||||
|
proposed-to-close marker (listing tag) and notice strip (detail page).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
PROPOSED = "Open, but proposed to close"
|
||||||
|
|
||||||
|
|
||||||
|
def _schools_df() -> pd.DataFrame:
|
||||||
|
base = {
|
||||||
|
"local_authority": "Testshire",
|
||||||
|
"school_type": "Academy",
|
||||||
|
"phase": "Secondary",
|
||||||
|
"address": "1 Test Street",
|
||||||
|
"town": "Testtown",
|
||||||
|
"postcode": "TS1 1AA",
|
||||||
|
"religious_denomination": None,
|
||||||
|
"gender": "Mixed",
|
||||||
|
"age_range": "11-16",
|
||||||
|
"admissions_policy": None,
|
||||||
|
"has_sixth_form": False,
|
||||||
|
"ofsted_grade": np.nan,
|
||||||
|
"ofsted_date": None,
|
||||||
|
"ofsted_framework": None,
|
||||||
|
"latitude": 51.5,
|
||||||
|
"longitude": -0.1,
|
||||||
|
"year": 202425,
|
||||||
|
"total_pupils": 800,
|
||||||
|
"rwm_expected_pct": np.nan,
|
||||||
|
"attainment_8_score": 48.0,
|
||||||
|
}
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
{**base, "urn": 200001, "school_name": "Alpha Academy",
|
||||||
|
"status": "Open"},
|
||||||
|
{**base, "urn": 200002, "school_name": "Sarson High School",
|
||||||
|
"status": PROPOSED},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_payload_includes_status(client):
|
||||||
|
resp = client.get("/api/schools")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
|
||||||
|
assert by_urn[200001]["status"] == "Open"
|
||||||
|
assert by_urn[200002]["status"] == PROPOSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_payload_includes_status(client):
|
||||||
|
resp = client.get("/api/schools/200002")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["school_info"]["status"] == PROPOSED
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
|
||||||
|
|
||||||
|
The filter and payloads must use dim_school.has_sixth_form, not the old
|
||||||
|
age_range-contains-"18" substring heuristic. The key regression case is a
|
||||||
|
16-19 sixth-form college: flag true, but "16-19" contains no "18".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _schools_df() -> pd.DataFrame:
|
||||||
|
"""Latest-year snapshot rows as produced by load_latest_school_data."""
|
||||||
|
base = {
|
||||||
|
"local_authority": "Testshire",
|
||||||
|
"school_type": "Academy",
|
||||||
|
"phase": "Secondary",
|
||||||
|
"address": "1 Test Street",
|
||||||
|
"town": "Testtown",
|
||||||
|
"postcode": "TS1 1AA",
|
||||||
|
"religious_denomination": None,
|
||||||
|
"gender": "Mixed",
|
||||||
|
"admissions_policy": None,
|
||||||
|
"ofsted_grade": np.nan,
|
||||||
|
"ofsted_date": None,
|
||||||
|
"ofsted_framework": None,
|
||||||
|
"latitude": 51.5,
|
||||||
|
"longitude": -0.1,
|
||||||
|
"year": 202425,
|
||||||
|
"total_pupils": 1000,
|
||||||
|
"rwm_expected_pct": np.nan,
|
||||||
|
"attainment_8_score": 50.0,
|
||||||
|
}
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
# 11-18 school WITH a registered sixth form
|
||||||
|
{**base, "urn": 100001, "school_name": "Alpha High",
|
||||||
|
"age_range": "11-18", "has_sixth_form": True},
|
||||||
|
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
|
||||||
|
# GIAS flag says YES — must appear in the yes-filter results
|
||||||
|
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
|
||||||
|
"age_range": "16-19", "has_sixth_form": True},
|
||||||
|
# 11-18 age range on paper but NO registered sixth form:
|
||||||
|
# old heuristic said YES, GIAS flag says NO
|
||||||
|
{**base, "urn": 100003, "school_name": "Gamma Academy",
|
||||||
|
"age_range": "11-18", "has_sixth_form": False},
|
||||||
|
# Missing flag (pipeline not yet re-run) — must not crash,
|
||||||
|
# must not match the yes-filter
|
||||||
|
{**base, "urn": 100004, "school_name": "Delta School",
|
||||||
|
"age_range": "11-16", "has_sixth_form": None},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _urns(resp):
|
||||||
|
return sorted(s["urn"] for s in resp.json()["schools"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_yes_uses_flag_not_age_range(client):
|
||||||
|
resp = client.get("/api/schools?has_sixth_form=yes")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# 16-19 college included; 11-18-without-sixth-form excluded
|
||||||
|
assert _urns(resp) == [100001, 100002]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_no_uses_flag_not_age_range(client):
|
||||||
|
resp = client.get("/api/schools?has_sixth_form=no")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Gamma (flag false) and Delta (flag missing => not true)
|
||||||
|
assert _urns(resp) == [100003, 100004]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_payload_includes_flag(client):
|
||||||
|
resp = client.get("/api/schools")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
|
||||||
|
assert by_urn[100002]["has_sixth_form"] is True
|
||||||
|
assert by_urn[100003]["has_sixth_form"] is False
|
||||||
|
assert by_urn[100004]["has_sixth_form"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_payload_includes_flag(client):
|
||||||
|
resp = client.get("/api/schools/100002")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["school_info"]["has_sixth_form"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_payload_serializes_numpy_bool(monkeypatch):
|
||||||
|
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
|
||||||
|
(dbt not_null test guarantees no NULLs), so row access yields
|
||||||
|
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
|
||||||
|
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
|
||||||
|
endpoint 500s (C2)."""
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
df = _schools_df()
|
||||||
|
# Drop the row with a None flag — this fixture models the post-pipeline
|
||||||
|
# state where the column is a genuine, fully-populated bool dtype.
|
||||||
|
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
|
||||||
|
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
|
||||||
|
assert df["has_sixth_form"].dtype == bool
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||||
|
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
resp = client.get("/api/schools/100002")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["school_info"]["has_sixth_form"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
|
||||||
|
"""Real prod state until the nightly pipeline first rebuilds the mart:
|
||||||
|
marts.dim_school lacks has_sixth_form entirely. The first query raises
|
||||||
|
UndefinedColumn; load_school_data_as_dataframe must retry without the
|
||||||
|
column (synthesizing it as None) rather than swallow the error and
|
||||||
|
return (and then have load_school_data cache) an empty DataFrame (C1)."""
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from backend import data_loader
|
||||||
|
|
||||||
|
data_loader._df_cache = None
|
||||||
|
data_loader._df_latest_cache = None
|
||||||
|
|
||||||
|
good_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"urn": 1,
|
||||||
|
"school_name": "Fallback School",
|
||||||
|
"school_type": "Academy",
|
||||||
|
"has_sixth_form": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_read_sql(query, con):
|
||||||
|
calls.append(query)
|
||||||
|
if len(calls) == 1:
|
||||||
|
# The statement text still contains phase_code, school_type_code,
|
||||||
|
# etc. (it's the full _MAIN_QUERY SELECT list) — that's exactly
|
||||||
|
# the collision this test guards against: matching must be done
|
||||||
|
# against exc.orig (the DBAPI error), not str(exc)/the statement.
|
||||||
|
raise sqlalchemy.exc.ProgrammingError(
|
||||||
|
statement=str(data_loader._MAIN_QUERY),
|
||||||
|
params=None,
|
||||||
|
orig=Exception(
|
||||||
|
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
|
||||||
|
"does not exist"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return good_df.copy()
|
||||||
|
|
||||||
|
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = data_loader.load_school_data_as_dataframe()
|
||||||
|
finally:
|
||||||
|
data_loader._df_cache = None
|
||||||
|
data_loader._df_latest_cache = None
|
||||||
|
|
||||||
|
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
|
||||||
|
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
|
||||||
|
assert not df.empty
|
||||||
|
assert "has_sixth_form" in df.columns
|
||||||
|
assert df["has_sixth_form"].iloc[0] is None
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""get_supplementary_data_batch fetches one query per table for all URNs
|
||||||
|
(not ~5 per school) and returns the same per-URN block shape as the
|
||||||
|
single-URN function, picking the latest row per URN where relevant."""
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
from backend import data_loader
|
||||||
|
from backend.data_loader import get_supplementary_data_batch
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||||
|
|
||||||
|
def __init__(self, recorder, model_name, rows):
|
||||||
|
self._rec = recorder
|
||||||
|
self._model = model_name
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
self._rec.append(self._model)
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
self._rec.append(self._model)
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, rows_by_model):
|
||||||
|
self.rows_by_model = rows_by_model
|
||||||
|
self.queries: list[str] = []
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
name = model.__name__
|
||||||
|
return _FakeQuery(self.queries, name, self.rows_by_model.get(name, []))
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ofsted_row(urn, date, oe):
|
||||||
|
base = {f: None for f in (
|
||||||
|
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
|
||||||
|
"personal_development", "leadership_management", "early_years_provision",
|
||||||
|
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
|
||||||
|
"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", "report_url",
|
||||||
|
)}
|
||||||
|
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
|
||||||
|
overall_effectiveness=oe, grade_source=None)
|
||||||
|
return types.SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _adm_row(urn, year):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
urn=urn, year=year, school_phase="Primary", places_offered=100,
|
||||||
|
total_applications=200, first_preference_applications=150,
|
||||||
|
first_preference_offers=140, first_preference_offer_pct=93.3,
|
||||||
|
oversubscription_ratio=1.5, oversubscribed=True,
|
||||||
|
total_offers=100, second_preference_offers=5, third_preference_offers=2,
|
||||||
|
cross_la_applications=10, cross_la_offers=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_query_per_table_and_latest_row_per_urn():
|
||||||
|
rows = {
|
||||||
|
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||||
|
"FactOfstedInspection": [
|
||||||
|
_ofsted_row(1, "2023-01-01", 2),
|
||||||
|
_ofsted_row(1, "2019-01-01", 3),
|
||||||
|
_ofsted_row(2, "2021-06-01", 1),
|
||||||
|
],
|
||||||
|
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||||
|
"FactPupilCharacteristics": [],
|
||||||
|
"FactDeprivation": [],
|
||||||
|
"FactFinance": [],
|
||||||
|
}
|
||||||
|
session = _FakeSession(rows)
|
||||||
|
out = get_supplementary_data_batch(session, [1, 2])
|
||||||
|
|
||||||
|
# Exactly one query per table — five total, regardless of two URNs.
|
||||||
|
assert sorted(session.queries) == [
|
||||||
|
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||||
|
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Latest Ofsted kept per URN
|
||||||
|
assert out[1]["ofsted"]["overall_effectiveness"] == 2
|
||||||
|
assert out[2]["ofsted"]["overall_effectiveness"] == 1
|
||||||
|
|
||||||
|
# Admissions history grouped per URN, latest exposed as `admissions`
|
||||||
|
assert [r["year"] for r in out[1]["admissions_history"]] == [202526, 202627]
|
||||||
|
assert out[1]["admissions"]["year"] == 202627
|
||||||
|
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||||
|
|
||||||
|
# Empty tables degrade to the null block, not a crash
|
||||||
|
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_wrapper_matches_batch(monkeypatch):
|
||||||
|
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
|
||||||
|
single = data_loader.get_supplementary_data(session, 5)
|
||||||
|
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||||
|
assert single["admissions_history"] == []
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Supplementary-block enrichment for the compare redesign: report-card
|
||||||
|
labels, provider-page URL, graded-vs-carried-forward provenance, and the
|
||||||
|
admissions preference/cross-LA detail promoted in the data-foundation PR."""
|
||||||
|
|
||||||
|
import types
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from backend.data_loader import _admissions_row_dict, _ofsted_block
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kw):
|
||||||
|
base = dict(
|
||||||
|
framework="RC", inspection_date=None, inspection_type=None,
|
||||||
|
overall_effectiveness=None, quality_of_education=None,
|
||||||
|
behaviour_attitudes=None, personal_development=None,
|
||||||
|
leadership_management=None, early_years_provision=None,
|
||||||
|
sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None,
|
||||||
|
rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None,
|
||||||
|
rc_achievement=None, rc_attendance_behaviour=None,
|
||||||
|
rc_personal_development=None, rc_leadership_governance=None,
|
||||||
|
rc_early_years=None, rc_sixth_form=None, report_url=None,
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return types.SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_card_block_and_provider_url():
|
||||||
|
o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True)
|
||||||
|
block = _ofsted_block(o, urn=100140)
|
||||||
|
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
|
||||||
|
assert "rc_safeguarding_met" not in block["report_card"]
|
||||||
|
assert block["rc_safeguarding_met"] is True
|
||||||
|
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grade_source_graded_vs_carried_forward():
|
||||||
|
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
|
||||||
|
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
|
||||||
|
assert carried["grade_source"] == "ungraded_carried_forward"
|
||||||
|
assert carried["overall_effectiveness"] == 2
|
||||||
|
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_carries_rc_inspection_date():
|
||||||
|
o = _row(
|
||||||
|
ungraded_grade=2,
|
||||||
|
rc_achievement=1,
|
||||||
|
rc_inspection_date=date(2026, 2, 3),
|
||||||
|
inspection_date=date(2021, 10, 7),
|
||||||
|
)
|
||||||
|
block = _ofsted_block(o, urn=138690)
|
||||||
|
assert block["rc_inspection_date"] == "2026-02-03"
|
||||||
|
# The legacy inspection date is still present, unchanged.
|
||||||
|
assert block["inspection_date"] == "2021-10-07"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||||
|
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||||
|
block = _ofsted_block(o, urn=136276)
|
||||||
|
assert block["rc_inspection_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_keeps_existing_keys():
|
||||||
|
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
|
||||||
|
for key in ("framework", "inspection_date", "overall_effectiveness",
|
||||||
|
"quality_of_education", "rc_inclusion", "report_url"):
|
||||||
|
assert key in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_admissions_row_new_fields():
|
||||||
|
a = types.SimpleNamespace(
|
||||||
|
year=202627, school_phase="Primary", places_offered=80,
|
||||||
|
total_applications=185, first_preference_applications=74,
|
||||||
|
first_preference_offers=74, first_preference_offer_pct=100.0,
|
||||||
|
oversubscription_ratio=0.925, oversubscribed=False,
|
||||||
|
total_offers=80, second_preference_offers=4, third_preference_offers=2,
|
||||||
|
cross_la_applications=12, cross_la_offers=3,
|
||||||
|
)
|
||||||
|
d = _admissions_row_dict(a)
|
||||||
|
for k in ("total_offers", "second_preference_offers", "third_preference_offers",
|
||||||
|
"cross_la_applications", "cross_la_offers"):
|
||||||
|
assert d[k] == getattr(a, k)
|
||||||
|
# Existing keys unchanged
|
||||||
|
assert d["first_preference_offer_pct"] == 100.0
|
||||||
|
assert d["oversubscribed"] is False
|
||||||
@@ -11,6 +11,8 @@ def convert_to_native(value: Any) -> Any:
|
|||||||
"""Convert numpy types to native Python types for JSON serialization."""
|
"""Convert numpy types to native Python types for JSON serialization."""
|
||||||
if pd.isna(value):
|
if pd.isna(value):
|
||||||
return None
|
return None
|
||||||
|
if isinstance(value, np.bool_):
|
||||||
|
return bool(value)
|
||||||
if isinstance(value, (np.integer,)):
|
if isinstance(value, (np.integer,)):
|
||||||
return int(value)
|
return int(value)
|
||||||
if isinstance(value, (np.floating,)):
|
if isinstance(value, (np.floating,)):
|
||||||
|
|||||||
+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,30 @@ 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 **to staging only**: images are
|
||||||
|
built once, deployed to the staging Portainer stack, and verified by the
|
||||||
|
Playwright journeys in `e2e/`. Production is a second, manual approval:
|
||||||
|
the "Promote to Production (manual)" workflow in Gitea Actions, run after
|
||||||
|
testing the feature on staging. It refuses commits whose staging E2E gate
|
||||||
|
isn't green. Never trigger it yourself — promotion is the human's call.
|
||||||
|
- If you change user-facing behaviour, update or extend the `e2e/` journey
|
||||||
|
tests in the same PR — they gate whether staging is fit for human testing
|
||||||
|
and whether a commit is promotable.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Portainer Stack Definition for School Compare — STAGING
|
||||||
|
#
|
||||||
|
# Deploy this as a *separate* Portainer stack (e.g. "schoolcompare-staging")
|
||||||
|
# alongside the production stack. Differences from production:
|
||||||
|
# - images pinned to :staging (pushed by every merge to main, before the E2E gate)
|
||||||
|
# - sc_staging_* container names
|
||||||
|
# - own macvlan IPs (STAGING_DB_IP / STAGING_FRONTEND_IP env vars)
|
||||||
|
# - Airflow UI published on 8081 (prod uses 8080)
|
||||||
|
# - volumes are isolated automatically: Portainer prefixes volume names with
|
||||||
|
# the stack name, so this stack gets its own postgres/typesense/airflow data
|
||||||
|
#
|
||||||
|
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
|
||||||
|
# DB_USERNAME — PostgreSQL username
|
||||||
|
# DB_PASSWORD — PostgreSQL password
|
||||||
|
# DB_DATABASE_NAME — PostgreSQL database name
|
||||||
|
# ADMIN_API_KEY — Backend admin API key
|
||||||
|
# TYPESENSE_API_KEY — Typesense admin API key
|
||||||
|
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||||
|
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||||
|
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
|
||||||
|
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── PostgreSQL ────────────────────────────────────────────────────────
|
||||||
|
sc_database:
|
||||||
|
container_name: sc_staging_postgres
|
||||||
|
image: postgis/postgis:18-3.6-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
|
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql
|
||||||
|
shm_size: 128mb
|
||||||
|
networks:
|
||||||
|
backend: {}
|
||||||
|
macvlan:
|
||||||
|
ipv4_address: ${STAGING_DB_IP:-10.0.1.190}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── FastAPI Backend ───────────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
image: privaterepo.sitaru.org/tudor/school_compare-backend:staging
|
||||||
|
container_name: sc_staging_backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||||
|
PYTHONUNBUFFERED: 1
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||||
|
TYPESENSE_URL: http://typesense:8108
|
||||||
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
depends_on:
|
||||||
|
sc_database:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/api/data-info"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ── Next.js Frontend ──────────────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
image: privaterepo.sitaru.org/tudor/school_compare-frontend:staging
|
||||||
|
container_name: sc_staging_nextjs
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||||
|
- FASTAPI_URL=http://backend:80/api
|
||||||
|
- TYPESENSE_URL=http://typesense:8108
|
||||||
|
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
backend: {}
|
||||||
|
macvlan:
|
||||||
|
ipv4_address: ${STAGING_FRONTEND_IP:-10.0.1.151}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# ── Typesense Search Engine ───────────────────────────────────────────
|
||||||
|
typesense:
|
||||||
|
image: typesense/typesense:30.1
|
||||||
|
container_name: sc_staging_typesense
|
||||||
|
environment:
|
||||||
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
TYPESENSE_DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- typesense_data:/data
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "cat < /dev/tcp/localhost/8108"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# ── Airflow API Server + UI (staging: http://<host>:8081) ─────────────
|
||||||
|
airflow-api-server:
|
||||||
|
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||||
|
container_name: sc_staging_airflow_api
|
||||||
|
command: airflow api-server --port 8080
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
environment:
|
||||||
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||||
|
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||||
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
|
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||||
|
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||||
|
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||||
|
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||||
|
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||||
|
PG_HOST: sc_database
|
||||||
|
PG_PORT: "5432"
|
||||||
|
PG_USER: ${DB_USERNAME}
|
||||||
|
PG_PASSWORD: ${DB_PASSWORD}
|
||||||
|
PG_DATABASE: ${DB_DATABASE_NAME}
|
||||||
|
TYPESENSE_URL: http://typesense:8108
|
||||||
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
volumes:
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
depends_on:
|
||||||
|
sc_database:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v2/monitor/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# ── Airflow Scheduler ──────────────────────────────────────────────
|
||||||
|
airflow-scheduler:
|
||||||
|
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||||
|
container_name: sc_staging_airflow_scheduler
|
||||||
|
command: airflow scheduler
|
||||||
|
environment:
|
||||||
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||||
|
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||||
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
|
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||||
|
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||||
|
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||||
|
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||||
|
PG_HOST: sc_database
|
||||||
|
PG_PORT: "5432"
|
||||||
|
PG_USER: ${DB_USERNAME}
|
||||||
|
PG_PASSWORD: ${DB_PASSWORD}
|
||||||
|
PG_DATABASE: ${DB_DATABASE_NAME}
|
||||||
|
TYPESENSE_URL: http://typesense:8108
|
||||||
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
volumes:
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
depends_on:
|
||||||
|
sc_database:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
|
||||||
|
airflow-init:
|
||||||
|
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||||
|
container_name: sc_staging_airflow_init
|
||||||
|
command: bash -c "airflow db migrate && airflow dags reserialize"
|
||||||
|
environment:
|
||||||
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||||
|
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
|
||||||
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
|
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
|
||||||
|
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||||
|
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||||
|
depends_on:
|
||||||
|
sc_database:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
driver: bridge
|
||||||
|
macvlan:
|
||||||
|
external:
|
||||||
|
name: macvlan
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
typesense_data:
|
||||||
|
airflow_logs:
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
|
|
||||||
# ── FastAPI Backend ───────────────────────────────────────────────────
|
# ── 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}
|
||||||
@@ -59,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,7 +103,7 @@ services:
|
|||||||
|
|
||||||
# ── 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:
|
||||||
@@ -142,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:
|
||||||
@@ -172,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:
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
# SDLC & Deployment Pipeline
|
||||||
|
|
||||||
|
SchoolCompare uses a two-stage deploy model on Gitea Actions with two human
|
||||||
|
approvals. AI writes the code on feature branches; the first approval merges
|
||||||
|
the PR, which deploys to staging and runs the E2E gate; the second approval —
|
||||||
|
after manual testing on staging — promotes the exact same images to
|
||||||
|
production via a manual workflow.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
```
|
||||||
|
feature branch (AI-authored)
|
||||||
|
│ PR to main ← approval #1
|
||||||
|
▼
|
||||||
|
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)
|
||||||
|
▼
|
||||||
|
Stage pipeline (.gitea/workflows/deploy.yml) — automatic
|
||||||
|
1. build & push images → tags sha-<sha>, staging
|
||||||
|
2. staging Portainer webhook → wait for staging health
|
||||||
|
3. Playwright E2E journeys against staging ← gate before human testing
|
||||||
|
▼
|
||||||
|
Manual testing on staging (stx.schoolcompare.co.uk)
|
||||||
|
│ Actions → "Promote to Production (manual)" ← approval #2
|
||||||
|
▼
|
||||||
|
Promote pipeline (.gitea/workflows/promote.yml) — manual dispatch
|
||||||
|
1. resolve target sha (input, or latest main if empty)
|
||||||
|
2. REFUSE unless that commit's "E2E Journeys against Staging" status is green
|
||||||
|
3. retag sha-<sha> → :prod (same bytes — build once, promote the image)
|
||||||
|
previous :prod saved as :prod-previous
|
||||||
|
4. prod Portainer webhook → wait for prod health
|
||||||
|
```
|
||||||
|
|
||||||
|
Key principle: **build once, promote the exact image**. Production pins `:prod`,
|
||||||
|
which only moves when a human runs the promote workflow — and the workflow
|
||||||
|
only accepts commits that passed the staging E2E gate. 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` releases **to staging only**. Production moves only on
|
||||||
|
the second approval. If staging or the E2E gate fails, fix forward —
|
||||||
|
production is untouched either way.
|
||||||
|
|
||||||
|
## Promotion granularity
|
||||||
|
|
||||||
|
Staging always runs the latest `main`. Promoting approves a *state of main*,
|
||||||
|
not a single PR — if two PRs merged since the last promotion, they ship
|
||||||
|
together. Test staging accordingly. To promote an older state, pass its
|
||||||
|
commit SHA to the promote workflow (its images must still exist in the
|
||||||
|
registry).
|
||||||
|
|
||||||
|
Staging quirk for manual testing: external `/api` is broken at the staging
|
||||||
|
proxy — exercise API endpoints from the host, not via the public staging URL.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Re-run "Promote to Production (manual)" with the SHA of the last good commit
|
||||||
|
(fastest, fully gated), or manually re-point the tags — every promotion first
|
||||||
|
saves the outgoing `:prod` as `:prod-previous`:
|
||||||
|
|
||||||
|
```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,560 @@
|
|||||||
|
# GIAS OfficialSixthForm Flag Implementation 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:** Ingest GIAS's authoritative `OfficialSixthForm` flag into `marts.dim_school.has_sixth_form` and replace every `age_range contains "18"` heuristic in the backend and frontend with it.
|
||||||
|
|
||||||
|
**Architecture:** Data flows tap → raw → dbt staging → dbt mart → backend SQL → API payload → Next.js components. The GIAS Singer tap must declare the new CSV column (target-postgres only persists declared columns); the dbt staging model renames it; `dim_school` derives a boolean (with a statutory-age fallback for blank GIAS values); the backend exposes it on list + detail payloads and uses it for the `has_sixth_form=yes|no` filter; the frontend badge/note/filter-labels switch from the age-range substring check to the flag.
|
||||||
|
|
||||||
|
**Tech Stack:** Singer SDK (tap), dbt (Postgres), FastAPI + pandas, Next.js + TypeScript, pytest, Jest/RTL.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` §3.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` = `"Has a sixth form"`. `"Does not have a sixth form"` and `"Not applicable"` → false. Blank/NULL (rare) → fall back to `statutory_high_age >= 18`.
|
||||||
|
- The public API filter parameter stays `has_sixth_form=yes|no` (unchanged contract).
|
||||||
|
- Filter dropdown labels must drop the age-range parentheticals: "With sixth form" / "Without sixth form" (sixth form ≠ age range).
|
||||||
|
- Never push to `main`; work stays on branch `feat/gias-sixth-form-flag` (create from `docs/exam-phase-taxonomy` so the spec is included, or from `main` if that branch has merged).
|
||||||
|
- The dbt models cannot be run locally (no pipeline DB); dbt changes are verified by review + `python -c` schema asserts + existing CI. Do NOT attempt to start a local server.
|
||||||
|
- The backend marts tables are dbt `table` materializations — rebuilt on every pipeline run, so **no ALTER TABLE migration is needed** for `marts.dim_school`.
|
||||||
|
- Deployment ordering: the tap must run before dbt on the first pipeline run after deploy (this is already the DAG order: extract → transform). Until that run happens, `has_sixth_form` is absent from the DB; the backend must treat a missing column as "flag false / fallback", never crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Ingest `OfficialSixthForm (name)` — tap schema + dbt staging
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py:31-66` (Singer schema)
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql` (add renamed column)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: raw column `"OfficialSixthForm (name)"` in `raw.gias_establishments`; staging column `official_sixth_form` (text: `Has a sixth form` / `Does not have a sixth form` / `Not applicable` / NULL) consumed by Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the property to the Singer schema**
|
||||||
|
|
||||||
|
In `tap.py`, inside `GIASEstablishmentsStream.schema = th.PropertiesList(...)`, add after the `th.Property("PhaseOfEducation (name)", th.StringType),` line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
th.Property("OfficialSixthForm (name)", th.StringType),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the tap module still imports and declares the column**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/tudor/projects/school_compare/pipeline/plugins/extractors/tap-uk-gias && \
|
||||||
|
python3 -c "
|
||||||
|
import ast, sys
|
||||||
|
src = open('tap_uk_gias/tap.py').read()
|
||||||
|
ast.parse(src)
|
||||||
|
assert '\"OfficialSixthForm (name)\"' in src.replace(\"'\", '\"')
|
||||||
|
print('OK: tap declares OfficialSixthForm (name)')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `OK: tap declares OfficialSixthForm (name)`
|
||||||
|
(Uses `ast.parse` instead of importing because `singer_sdk` is not installed locally.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the column to the staging model**
|
||||||
|
|
||||||
|
In `stg_gias_establishments.sql`, in the `renamed` CTE, add after the `"PhaseOfEducation (name)" as phase,` line:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Sanity-check the SQL edit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -n "official_sixth_form" /Users/tudor/projects/school_compare/pipeline/transform/models/staging/stg_gias_establishments.sql
|
||||||
|
```
|
||||||
|
Expected: one line showing the new column inside the `renamed` CTE (before `from source`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
|
||||||
|
git commit -m "feat(pipeline): ingest GIAS OfficialSixthForm into staging
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Derive `dim_school.has_sixth_form` (dbt mart + schema tests + SQLAlchemy model)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/marts/dim_school.sql` (add derived column)
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (document + test the column)
|
||||||
|
- Modify: `backend/models.py:13-38` (`DimSchool` — add column)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `official_sixth_form` text column from Task 1's staging model.
|
||||||
|
- Produces: `marts.dim_school.has_sixth_form boolean not null`, and `DimSchool.has_sixth_form = Column(Boolean)` for the backend. Task 3 selects it as `s.has_sixth_form`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the derived column to `dim_school.sql`**
|
||||||
|
|
||||||
|
In the `select`, add after the `s.age_range` line (`s.statutory_low_age || '-' || s.statutory_high_age as age_range,`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Authoritative sixth-form flag (spec §3): GIAS OfficialSixthForm.
|
||||||
|
-- "Not applicable" (nurseries, primaries, PRUs) => false. Blank GIAS
|
||||||
|
-- value (rare, new establishments) falls back to the statutory age range.
|
||||||
|
case
|
||||||
|
when s.official_sixth_form = 'Has a sixth form' then true
|
||||||
|
when s.official_sixth_form in ('Does not have a sixth form', 'Not applicable') then false
|
||||||
|
else coalesce(s.statutory_high_age >= 18, false)
|
||||||
|
end as has_sixth_form,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add schema documentation + tests in `_marts_schema.yml`**
|
||||||
|
|
||||||
|
Under `- name: dim_school` → `columns:`, add after the `phase` column block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: has_sixth_form
|
||||||
|
description: >
|
||||||
|
Authoritative sixth-form flag from GIAS OfficialSixthForm.
|
||||||
|
"Has a sixth form" => true; "Does not have a sixth form" and
|
||||||
|
"Not applicable" => false; blank GIAS value falls back to
|
||||||
|
statutory_high_age >= 18. Replaces the age_range-contains-"18"
|
||||||
|
heuristic (spec 2026-07-07 §3).
|
||||||
|
tests:
|
||||||
|
- not_null
|
||||||
|
- accepted_values:
|
||||||
|
values: [true, false]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the column to the `DimSchool` SQLAlchemy model**
|
||||||
|
|
||||||
|
In `backend/models.py`, in `class DimSchool`, add after `age_range = Column(String(20))`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
has_sixth_form = Column(Boolean)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify SQL/YAML/Python all parse**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/tudor/projects/school_compare && \
|
||||||
|
python3 -c "
|
||||||
|
import yaml
|
||||||
|
y = yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml'))
|
||||||
|
dim = [m for m in y['models'] if m['name'] == 'dim_school'][0]
|
||||||
|
cols = [c['name'] for c in dim['columns']]
|
||||||
|
assert 'has_sixth_form' in cols, cols
|
||||||
|
print('OK: schema yml documents has_sixth_form')
|
||||||
|
" && \
|
||||||
|
grep -c "has_sixth_form" pipeline/transform/models/marts/dim_school.sql && \
|
||||||
|
python3 -c "import ast; ast.parse(open('backend/models.py').read()); print('OK: models.py parses')"
|
||||||
|
```
|
||||||
|
Expected: `OK: schema yml documents has_sixth_form`, grep count `>= 1`, `OK: models.py parses`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/_marts_schema.yml backend/models.py
|
||||||
|
git commit -m "feat(pipeline): derive dim_school.has_sixth_form from GIAS flag
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Backend — expose `has_sixth_form` and replace the filter heuristic
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/data_loader.py:117-215` (`_MAIN_QUERY` — select the column)
|
||||||
|
- Modify: `backend/schemas.py:536-553` (`SCHOOL_COLUMNS` — include in list payloads)
|
||||||
|
- Modify: `backend/app.py:419-422` (filter) and `backend/app.py:589-610` (detail `school_info`)
|
||||||
|
- Test: `backend/tests/test_sixth_form_flag.py` (new)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `marts.dim_school.has_sixth_form` (Task 2).
|
||||||
|
- Produces: `has_sixth_form: bool | null` field on `GET /api/schools` items and on `GET /api/schools/{urn}` → `school_info`. Filter `GET /api/schools?has_sixth_form=yes|no` now driven by the flag. Frontend (Task 4) reads `school.has_sixth_form`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `backend/tests/test_sixth_form_flag.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
|
||||||
|
|
||||||
|
The filter and payloads must use dim_school.has_sixth_form, not the old
|
||||||
|
age_range-contains-"18" substring heuristic. The key regression case is a
|
||||||
|
16-19 sixth-form college: flag true, but "16-19" contains no "18".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _schools_df() -> pd.DataFrame:
|
||||||
|
"""Latest-year snapshot rows as produced by load_latest_school_data."""
|
||||||
|
base = {
|
||||||
|
"local_authority": "Testshire",
|
||||||
|
"school_type": "Academy",
|
||||||
|
"phase": "Secondary",
|
||||||
|
"address": "1 Test Street",
|
||||||
|
"town": "Testtown",
|
||||||
|
"postcode": "TS1 1AA",
|
||||||
|
"religious_denomination": None,
|
||||||
|
"gender": "Mixed",
|
||||||
|
"admissions_policy": None,
|
||||||
|
"ofsted_grade": np.nan,
|
||||||
|
"ofsted_date": None,
|
||||||
|
"ofsted_framework": None,
|
||||||
|
"latitude": 51.5,
|
||||||
|
"longitude": -0.1,
|
||||||
|
"year": 202425,
|
||||||
|
"total_pupils": 1000,
|
||||||
|
"rwm_expected_pct": np.nan,
|
||||||
|
"attainment_8_score": 50.0,
|
||||||
|
}
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
# 11-18 school WITH a registered sixth form
|
||||||
|
{**base, "urn": 100001, "school_name": "Alpha High",
|
||||||
|
"age_range": "11-18", "has_sixth_form": True},
|
||||||
|
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
|
||||||
|
# GIAS flag says YES — must appear in the yes-filter results
|
||||||
|
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
|
||||||
|
"age_range": "16-19", "has_sixth_form": True},
|
||||||
|
# 11-18 age range on paper but NO registered sixth form:
|
||||||
|
# old heuristic said YES, GIAS flag says NO
|
||||||
|
{**base, "urn": 100003, "school_name": "Gamma Academy",
|
||||||
|
"age_range": "11-18", "has_sixth_form": False},
|
||||||
|
# Missing flag (pipeline not yet re-run) — must not crash,
|
||||||
|
# must not match the yes-filter
|
||||||
|
{**base, "urn": 100004, "school_name": "Delta School",
|
||||||
|
"age_range": "11-16", "has_sixth_form": None},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _urns(resp):
|
||||||
|
return sorted(s["urn"] for s in resp.json()["schools"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_yes_uses_flag_not_age_range(client):
|
||||||
|
resp = client.get("/api/schools?has_sixth_form=yes")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# 16-19 college included; 11-18-without-sixth-form excluded
|
||||||
|
assert _urns(resp) == [100001, 100002]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_no_uses_flag_not_age_range(client):
|
||||||
|
resp = client.get("/api/schools?has_sixth_form=no")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Gamma (flag false) and Delta (flag missing => not true)
|
||||||
|
assert _urns(resp) == [100003, 100004]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_payload_includes_flag(client):
|
||||||
|
resp = client.get("/api/schools")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
|
||||||
|
assert by_urn[100002]["has_sixth_form"] is True
|
||||||
|
assert by_urn[100003]["has_sixth_form"] is False
|
||||||
|
assert by_urn[100004]["has_sixth_form"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_payload_includes_flag(client):
|
||||||
|
resp = client.get("/api/schools/100002")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["school_info"]["has_sixth_form"] is True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
|
||||||
|
Expected: FAIL — `test_filter_yes_uses_flag_not_age_range` asserts `[100001, 100002]` but the age-range heuristic returns `[100001, 100003]`; the payload tests fail with `KeyError: 'has_sixth_form'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Select the column in `_MAIN_QUERY`**
|
||||||
|
|
||||||
|
In `backend/data_loader.py`, in `_MAIN_QUERY`, add after `s.age_range,`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
s.has_sixth_form,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Include it in list payloads**
|
||||||
|
|
||||||
|
In `backend/schemas.py`, in `SCHOOL_COLUMNS`, add after `"age_range",`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"has_sixth_form",
|
||||||
|
```
|
||||||
|
|
||||||
|
(`app.py` builds list responses from `SCHOOL_COLUMNS ∩ df.columns`, so a DB that predates the pipeline re-run simply omits the field — no crash.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace the filter heuristic in `app.py`**
|
||||||
|
|
||||||
|
Replace lines 419-422:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if has_sixth_form == "yes":
|
||||||
|
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
||||||
|
elif has_sixth_form == "no":
|
||||||
|
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
|
||||||
|
# yet populated by the pipeline) is treated as "no sixth form".
|
||||||
|
if has_sixth_form in ("yes", "no"):
|
||||||
|
if "has_sixth_form" in df_latest.columns:
|
||||||
|
flag = df_latest["has_sixth_form"].eq(True)
|
||||||
|
else: # DB predates the pipeline re-run — fall back to age range
|
||||||
|
flag = df_latest["age_range"].str.contains("18", na=False)
|
||||||
|
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the flag to the detail payload**
|
||||||
|
|
||||||
|
In `backend/app.py` `school_info` dict (line ~598), add after `"age_range": latest.get("age_range", ""),`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`convert_to_native` already maps NaN/None → null and numpy bools → bool.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the new tests**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
|
||||||
|
Expected: 4 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full backend suite**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests -v`
|
||||||
|
Expected: all pass (the pre-existing `test_school_details.py` df has no `has_sixth_form` column — `latest.get()` returns None, serialized as null).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/data_loader.py backend/schemas.py backend/app.py backend/tests/test_sixth_form_flag.py
|
||||||
|
git commit -m "feat(api): drive has_sixth_form filter and payloads from GIAS flag
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Frontend — badge, note, row tag, and filter labels use the flag
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/lib/types.ts:10-30` (`School` interface)
|
||||||
|
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:101` (badge + coming-soon note)
|
||||||
|
- Modify: `nextjs-app/components/SecondarySchoolRow.tsx:25-27` (row tag)
|
||||||
|
- Modify: `nextjs-app/components/FilterBar.tsx:370-372` (labels only — param name unchanged)
|
||||||
|
- Test: `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx` (new)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `has_sixth_form: boolean | null` on both list items and `school_info` (Task 3; both are typed as `School`).
|
||||||
|
- Produces: no new exports — behavior change only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/**
|
||||||
|
* SecondarySchoolRow — sixth-form tag must come from the GIAS
|
||||||
|
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
|
||||||
|
import type { School } from '@/lib/types';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
urn: 100002,
|
||||||
|
school_name: 'Beta Sixth Form College',
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy',
|
||||||
|
phase: 'Secondary',
|
||||||
|
gender: 'Mixed',
|
||||||
|
attainment_8_score: 50.0,
|
||||||
|
} as unknown as School;
|
||||||
|
|
||||||
|
describe('SecondarySchoolRow sixth-form tag', () => {
|
||||||
|
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Sixth form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag for an 11-18 school without a registered sixth form', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
|
||||||
|
Expected: FAIL — first test can't find "Sixth form" ("16-19" fails the substring check), second test finds an unexpected "Sixth form" tag. (If TS complains that `has_sixth_form` is not on `School`, that is the same failure — proceed.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the field to the `School` type**
|
||||||
|
|
||||||
|
In `nextjs-app/lib/types.ts`, in `export interface School`, add after `age_range: string | null;`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
has_sixth_form?: boolean | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Switch `SecondarySchoolRow` to the flag**
|
||||||
|
|
||||||
|
Replace the helper at `SecondarySchoolRow.tsx:25-27`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function hasSixthForm(school: School): boolean {
|
||||||
|
return school.age_range?.includes('18') ?? false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function hasSixthForm(school: School): boolean {
|
||||||
|
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||||||
|
return school.has_sixth_form ?? false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Switch `SecondarySchoolDetailView` to the flag**
|
||||||
|
|
||||||
|
Replace line 101:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||||||
|
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
|
||||||
|
```
|
||||||
|
|
||||||
|
(This drives both the header "Sixth form" badge at line ~230 and the "Post-16 destination data coming soon" note at line ~715 — no changes needed there.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Fix the filter labels in `FilterBar.tsx`**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<option value="yes">With sixth form (11-18)</option>
|
||||||
|
<option value="no">Without sixth form (11-16)</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<option value="yes">With sixth form</option>
|
||||||
|
<option value="no">Without sixth form</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the new test and verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
|
||||||
|
Expected: 3 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full frontend checks**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx tsc --noEmit && npx jest`
|
||||||
|
Expected: typecheck clean, all Jest suites pass.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Verify no heuristic remains**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "includes('18')\|contains(\"18\")" /Users/tudor/projects/school_compare/nextjs-app/components /Users/tudor/projects/school_compare/backend --include="*.tsx" --include="*.ts" --include="*.py" | grep -v test
|
||||||
|
```
|
||||||
|
Expected: only the documented fallback inside `app.py` (DB-predates-pipeline branch); no other hits.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add nextjs-app/lib/types.ts nextjs-app/components/SecondarySchoolRow.tsx nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx
|
||||||
|
git commit -m "feat(ui): sixth-form badge, note and filter labels use GIAS flag
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update the spec status + PR
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` (§3 "Pipeline change (future work)" → implemented)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: everything above merged into the branch.
|
||||||
|
- Produces: PR ready for review; e2e journeys are the promotion gate (no journey currently exercises the sixth-form filter, and the API contract is unchanged, so no e2e change is required — state this in the PR body).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mark spec §3 pipeline change as implemented**
|
||||||
|
|
||||||
|
In the spec, change the §3 heading `### Pipeline change (future work)` to `### Pipeline change (implemented 2026-07-07)` and append one line at the end of that subsection:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Implemented in `feat/gias-sixth-form-flag` — see
|
||||||
|
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md
|
||||||
|
git commit -m "docs: mark sixth-form flag pipeline change implemented
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push and open the PR (Gitea)**
|
||||||
|
|
||||||
|
Push the branch, then create the PR against `main` using the Gitea API via the git credential helper (token-header auth 401s on this Gitea; basic auth from `git credential fill` works):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/gias-sixth-form-flag
|
||||||
|
```
|
||||||
|
|
||||||
|
PR title: `feat: drive sixth-form separation from GIAS OfficialSixthForm flag`
|
||||||
|
PR body must note: (1) API contract unchanged (`has_sixth_form=yes|no`), (2) flag is NULL until the next pipeline run — backend and frontend degrade to "no sixth form" / age-range fallback, (3) no e2e journey change needed, and end with the standard generation footer.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify CI passes**
|
||||||
|
|
||||||
|
Watch the PR checks (typecheck, tests, builds, AI review). All must pass before merge; merging deploys to staging automatically.
|
||||||
@@ -0,0 +1,799 @@
|
|||||||
|
# GIAS Code Dictionaries Implementation 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:** Store the six GIAS classification fields as official DfE integer codes in the marts and translate code → name in application code, leaving the API contract (name strings) unchanged.
|
||||||
|
|
||||||
|
**Architecture:** A generation script downloads the public GIAS bulk CSV and emits the dictionaries (Python dicts + a dbt seed) from real data. The tap ingests the `(code)` columns, staging casts them, `dim_school`/`dim_location` keep only codes, and translation happens in exactly two places: `backend/data_loader.py` right after `pd.read_sql`, and `pipeline/scripts/sync_typesense.py` before indexing. A dbt seed test warns when DfE adds/renames a value; a parity test keeps the backend and pipeline dictionary copies identical.
|
||||||
|
|
||||||
|
**Tech Stack:** Singer SDK tap, dbt (Postgres), FastAPI + pandas, Typesense sync script, pytest.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Numeric code values are never assumed.** Every literal code used in SQL or yml (status filter, sixth-form derivation, phase cascade) must be verified against `pipeline/transform/seeds/gias_code_names.csv` generated in Task 1 from the live CSV. The literals written in this plan are best-current-knowledge and each carries a verification step.
|
||||||
|
- **Names served by the API must stay byte-identical** to today's strings (e.g. `Does not apply`, `Open, but proposed to close`) — UI heuristics compare exact strings.
|
||||||
|
- The `(name)` columns stay declared in the tap and present in raw; staging stops exposing them.
|
||||||
|
- `dim_school` and `dim_location` status filters must stay identical (API inner-joins them).
|
||||||
|
- Backend tests run via: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v` (no local pytest exists).
|
||||||
|
- dbt cannot run locally — dbt changes are verified statically (grep / yaml parse) + CI.
|
||||||
|
- Never push to `main`. Work on branch `feat/gias-code-dictionaries` (branch off `docs/gias-code-dictionaries` so the spec is included, or off `main` if that has merged).
|
||||||
|
- Commits end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
|
||||||
|
- Deploy runbook (accepted window, spec §7): merge → deploy → trigger `school_data_daily` immediately. No code-level fallback for the old-schema window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Dictionary generation script, canonical module, pipeline copy, seed
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pipeline/scripts/generate_gias_codes.py`
|
||||||
|
- Create: `backend/gias_codes.py` (content generated by the script)
|
||||||
|
- Create: `pipeline/scripts/gias_codes.py` (byte-identical copy)
|
||||||
|
- Create: `pipeline/transform/seeds/gias_code_names.csv` (generated)
|
||||||
|
- Test: `backend/tests/test_gias_codes.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `backend/gias_codes.py` exporting `SCHOOL_TYPE`, `ESTABLISHMENT_STATUS`, `PHASE_OF_EDUCATION`, `OFFICIAL_SIXTH_FORM`, `RELIGIOUS_CHARACTER`, `ADMISSIONS_POLICY` (each `dict[int, str]`) and `translate(code, mapping) -> str | None`. Task 4 imports these; Task 5 imports the pipeline copy; Task 3 reads code literals from the seed CSV.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `backend/tests/test_gias_codes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
|
||||||
|
|
||||||
|
The dictionaries are generated from the live GIAS bulk CSV by
|
||||||
|
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
|
||||||
|
contract, key sentinel values the marts/UI depend on, and that the pipeline
|
||||||
|
copy has not drifted from the canonical backend module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.gias_codes import (
|
||||||
|
ADMISSIONS_POLICY,
|
||||||
|
ESTABLISHMENT_STATUS,
|
||||||
|
OFFICIAL_SIXTH_FORM,
|
||||||
|
PHASE_OF_EDUCATION,
|
||||||
|
RELIGIOUS_CHARACTER,
|
||||||
|
SCHOOL_TYPE,
|
||||||
|
translate,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_known_code():
|
||||||
|
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
|
||||||
|
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_unknown_code_degrades_gracefully():
|
||||||
|
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_none_and_nan_return_none():
|
||||||
|
assert translate(None, ESTABLISHMENT_STATUS) is None
|
||||||
|
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_accepts_float_codes():
|
||||||
|
# pd.read_sql yields float columns when NULLs are present
|
||||||
|
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
|
||||||
|
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentinel_names_present():
|
||||||
|
"""Names the marts/UI compare against must exist verbatim."""
|
||||||
|
assert "Open" in ESTABLISHMENT_STATUS.values()
|
||||||
|
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
|
||||||
|
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
|
||||||
|
assert "Primary" in PHASE_OF_EDUCATION.values()
|
||||||
|
assert "Secondary" in PHASE_OF_EDUCATION.values()
|
||||||
|
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
|
||||||
|
assert all(len(d) > 0 for d in (
|
||||||
|
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
|
||||||
|
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_copy_is_identical():
|
||||||
|
canonical = (REPO / "backend" / "gias_codes.py").read_text()
|
||||||
|
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
|
||||||
|
assert canonical == copy, (
|
||||||
|
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
|
||||||
|
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_matches_dictionaries():
|
||||||
|
import csv
|
||||||
|
fields = {
|
||||||
|
"school_type": SCHOOL_TYPE,
|
||||||
|
"establishment_status": ESTABLISHMENT_STATUS,
|
||||||
|
"phase_of_education": PHASE_OF_EDUCATION,
|
||||||
|
"official_sixth_form": OFFICIAL_SIXTH_FORM,
|
||||||
|
"religious_character": RELIGIOUS_CHARACTER,
|
||||||
|
"admissions_policy": ADMISSIONS_POLICY,
|
||||||
|
}
|
||||||
|
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
|
||||||
|
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
|
||||||
|
with open(seed_path, newline="") as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
seed[row["field"]][int(row["code"])] = row["name"]
|
||||||
|
assert seed == fields
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
|
||||||
|
Expected: FAIL at import — `ModuleNotFoundError: No module named 'backend.gias_codes'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the generation script**
|
||||||
|
|
||||||
|
Create `pipeline/scripts/generate_gias_codes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Generate GIAS code->name dictionaries from the live bulk CSV.
|
||||||
|
|
||||||
|
Writes:
|
||||||
|
- backend/gias_codes.py (canonical Python module)
|
||||||
|
- pipeline/scripts/gias_codes.py (byte-identical copy)
|
||||||
|
- pipeline/transform/seeds/gias_code_names.csv (dbt seed for drift test)
|
||||||
|
|
||||||
|
Run from the repo root whenever the dbt drift test warns that DfE
|
||||||
|
added/renamed a value: python pipeline/scripts/generate_gias_codes.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
GIAS_URL = (
|
||||||
|
"https://ea-edubase-api-prod.azurewebsites.net"
|
||||||
|
"/edubase/downloads/public/edubasealldata{date}.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
# (CSV code column, CSV name column, python dict name, seed field key)
|
||||||
|
FIELDS = [
|
||||||
|
("TypeOfEstablishment (code)", "TypeOfEstablishment (name)", "SCHOOL_TYPE", "school_type"),
|
||||||
|
("EstablishmentStatus (code)", "EstablishmentStatus (name)", "ESTABLISHMENT_STATUS", "establishment_status"),
|
||||||
|
("PhaseOfEducation (code)", "PhaseOfEducation (name)", "PHASE_OF_EDUCATION", "phase_of_education"),
|
||||||
|
("OfficialSixthForm (code)", "OfficialSixthForm (name)", "OFFICIAL_SIXTH_FORM", "official_sixth_form"),
|
||||||
|
("ReligiousCharacter (code)", "ReligiousCharacter (name)", "RELIGIOUS_CHARACTER", "religious_character"),
|
||||||
|
("AdmissionsPolicy (code)", "AdmissionsPolicy (name)", "ADMISSIONS_POLICY", "admissions_policy"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MODULE_HEADER = '''"""GIAS code -> name dictionaries.
|
||||||
|
|
||||||
|
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
|
||||||
|
— do not edit by hand; rerun the script when the dbt drift test warns.
|
||||||
|
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
|
||||||
|
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
MODULE_FOOTER = '''
|
||||||
|
|
||||||
|
def translate(code, mapping: dict[int, str]) -> str | None:
|
||||||
|
"""Translate a GIAS code to its display name.
|
||||||
|
|
||||||
|
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
|
||||||
|
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
|
||||||
|
"""
|
||||||
|
if code is None or (isinstance(code, float) and math.isnan(code)):
|
||||||
|
return None
|
||||||
|
code = int(code)
|
||||||
|
if code not in mapping:
|
||||||
|
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
|
||||||
|
return f"Unknown ({code})"
|
||||||
|
return mapping[code]
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def download_csv() -> pd.DataFrame:
|
||||||
|
for day in (date.today(), date.today() - timedelta(days=1)):
|
||||||
|
url = GIAS_URL.format(date=day.strftime("%Y%m%d"))
|
||||||
|
print(f"Downloading {url}")
|
||||||
|
resp = requests.get(url, timeout=300)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return pd.read_csv(
|
||||||
|
io.StringIO(resp.content.decode("latin-1")),
|
||||||
|
dtype=str, keep_default_na=False,
|
||||||
|
)
|
||||||
|
sys.exit("GIAS CSV not available for today or yesterday")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
repo = Path(__file__).resolve().parents[2]
|
||||||
|
df = download_csv()
|
||||||
|
|
||||||
|
module_parts = [MODULE_HEADER]
|
||||||
|
seed_rows: list[tuple[str, int, str]] = []
|
||||||
|
|
||||||
|
for code_col, name_col, dict_name, field_key in FIELDS:
|
||||||
|
pairs = (
|
||||||
|
df[[code_col, name_col]]
|
||||||
|
.loc[lambda d: (d[code_col] != "") & (d[name_col] != "")]
|
||||||
|
.drop_duplicates()
|
||||||
|
)
|
||||||
|
mapping = sorted((int(c), n) for c, n in pairs.itertuples(index=False))
|
||||||
|
dupes = len(mapping) - len({c for c, _ in mapping})
|
||||||
|
if dupes:
|
||||||
|
sys.exit(f"{code_col}: {dupes} codes map to multiple names — investigate before generating")
|
||||||
|
lines = [f"{dict_name}: dict[int, str] = {{"]
|
||||||
|
for code, name in mapping:
|
||||||
|
escaped = name.replace('"', '\\"')
|
||||||
|
lines.append(f' {code}: "{escaped}",')
|
||||||
|
lines.append("}\n")
|
||||||
|
module_parts.append("\n".join(lines))
|
||||||
|
seed_rows += [(field_key, code, name) for code, name in mapping]
|
||||||
|
|
||||||
|
module = "\n".join(module_parts) + MODULE_FOOTER
|
||||||
|
|
||||||
|
(repo / "backend" / "gias_codes.py").write_text(module)
|
||||||
|
(repo / "pipeline" / "scripts" / "gias_codes.py").write_text(module)
|
||||||
|
|
||||||
|
seed_path = repo / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
|
||||||
|
with open(seed_path, "w", newline="") as fh:
|
||||||
|
import csv
|
||||||
|
w = csv.writer(fh)
|
||||||
|
w.writerow(["field", "code", "name"])
|
||||||
|
w.writerows(seed_rows)
|
||||||
|
|
||||||
|
print(f"Wrote backend/gias_codes.py, pipeline/scripts/gias_codes.py, {seed_path.name}")
|
||||||
|
print("\nKey codes for the dbt work (Task 3):")
|
||||||
|
for field in ("establishment_status", "phase_of_education", "official_sixth_form"):
|
||||||
|
print(f" {field}:")
|
||||||
|
for f, code, name in seed_rows:
|
||||||
|
if f == field:
|
||||||
|
print(f" {code} = {name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the generator**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && uv run --with pandas --with requests python pipeline/scripts/generate_gias_codes.py`
|
||||||
|
Expected: downloads the CSV (~100MB, may take a minute), writes the three files, and prints the status/phase/sixth-form code tables. **Record the printed code tables — Task 3 needs them.** If the download fails twice, report BLOCKED (no network or GIAS outage) rather than inventing dictionary content.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests again**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
|
||||||
|
Expected: 7 passed. If `test_sentinel_names_present` fails, the GIAS vocabulary differs from expectations — inspect the generated module and report DONE_WITH_CONCERNS naming the differing value; do not edit the generated names.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/scripts/generate_gias_codes.py backend/gias_codes.py pipeline/scripts/gias_codes.py pipeline/transform/seeds/gias_code_names.csv backend/tests/test_gias_codes.py
|
||||||
|
git commit -m "feat: GIAS code->name dictionaries generated from live bulk CSV
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Tap ingests the (code) columns; staging exposes codes, drops names
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py` (Singer schema)
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: staging columns `school_type_code`, `status_code`, `phase_code`, `official_sixth_form_code`, `religious_character_code`, `admissions_policy_code` (all int) consumed by Task 3. Staging **stops exposing** `school_type`, `status`, `phase`, `official_sixth_form`, `religious_character`, `admissions_policy` (names stay in raw only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the six (code) properties to the Singer schema**
|
||||||
|
|
||||||
|
In `tap.py`, `GIASEstablishmentsStream.schema`, add each `(code)` property directly above its existing `(name)` sibling:
|
||||||
|
|
||||||
|
```python
|
||||||
|
th.Property("TypeOfEstablishment (code)", th.StringType),
|
||||||
|
th.Property("PhaseOfEducation (code)", th.StringType),
|
||||||
|
th.Property("EstablishmentStatus (code)", th.StringType),
|
||||||
|
th.Property("Gender (name)", ...) # existing line — for placement reference only
|
||||||
|
th.Property("ReligiousCharacter (code)", th.StringType),
|
||||||
|
th.Property("AdmissionsPolicy (code)", th.StringType),
|
||||||
|
th.Property("OfficialSixthForm (code)", th.StringType),
|
||||||
|
```
|
||||||
|
|
||||||
|
(The exact insertion order doesn't matter — the schema is a dict — but keep each `(code)` adjacent to its `(name)` for readability. Do NOT remove any `(name)` property.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the six columns in staging**
|
||||||
|
|
||||||
|
In `stg_gias_establishments.sql` `renamed` CTE, replace:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
"TypeOfEstablishment (name)" as school_type,
|
||||||
|
"PhaseOfEducation (name)" as phase,
|
||||||
|
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
|
||||||
|
"ReligiousCharacter (name)" as religious_character,
|
||||||
|
"AdmissionsPolicy (name)" as admissions_policy,
|
||||||
|
"EstablishmentStatus (name)" as status,
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
cast(nullif(trim("TypeOfEstablishment (code)"), '') as integer) as school_type_code,
|
||||||
|
cast(nullif(trim("PhaseOfEducation (code)"), '') as integer) as phase_code,
|
||||||
|
cast(nullif(trim("OfficialSixthForm (code)"), '') as integer) as official_sixth_form_code,
|
||||||
|
cast(nullif(trim("ReligiousCharacter (code)"), '') as integer) as religious_character_code,
|
||||||
|
cast(nullif(trim("AdmissionsPolicy (code)"), '') as integer) as admissions_policy_code,
|
||||||
|
cast(nullif(trim("EstablishmentStatus (code)"), '') as integer) as status_code,
|
||||||
|
```
|
||||||
|
|
||||||
|
(The name lines are scattered through the CTE — replace each in place; the six name aliases must no longer appear in the model.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify statically**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/tudor/projects/school_compare && \
|
||||||
|
python3 -c "import ast; ast.parse(open('pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py').read()); print('tap OK')" && \
|
||||||
|
grep -c "(code)" pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py && \
|
||||||
|
grep -E "as (school_type|status|phase|official_sixth_form|religious_character|admissions_policy)," pipeline/transform/models/staging/stg_gias_establishments.sql; echo "name-alias grep exit=$? (want 1 = none found)"
|
||||||
|
```
|
||||||
|
Expected: `tap OK`, code-column count `6`, and the final grep finds nothing (exit 1).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
|
||||||
|
git commit -m "feat(pipeline): ingest GIAS code columns; staging exposes codes not names
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Marts store codes; dbt tests + drift test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/marts/dim_school.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/dim_location.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
|
||||||
|
- Create: `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: staging code columns from Task 2; code literals from `pipeline/transform/seeds/gias_code_names.csv` (Task 1).
|
||||||
|
- Produces: `dim_school` columns `school_type_code`, `status_code`, `phase_code`, `religious_character_code`, `admissions_policy_code` (int) replacing their string columns; `has_sixth_form` unchanged (bool). Task 4's `_MAIN_QUERY` selects these.
|
||||||
|
|
||||||
|
**Before writing SQL: open `pipeline/transform/seeds/gias_code_names.csv` and confirm the literals below.** Best-current-knowledge values (VERIFY EACH):
|
||||||
|
`establishment_status`: 1 = Open, 3 = "Open, but proposed to close" (2 = Closed, 4 = Proposed to open).
|
||||||
|
`phase_of_education`: 0 = Not applicable, 2 = Primary, 4 = Secondary, 7 = All-through.
|
||||||
|
`official_sixth_form`: 1 = Has a sixth form, 2 = Does not have a sixth form, 0 = Not applicable.
|
||||||
|
If any differ, use the seed's values everywhere below and say so in your report.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite dim_school.sql derivations in code space**
|
||||||
|
|
||||||
|
Replace the phase cascade block (`case ... end as phase,`) with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase in GIAS code space (see seeds/gias_code_names.csv):
|
||||||
|
-- 2 = Primary, 4 = Secondary, 7 = All-through, 0 = Not applicable.
|
||||||
|
case
|
||||||
|
-- 1. Trust GIAS phase when it's a real value (0 = the catch-all "Not Applicable")
|
||||||
|
when s.phase_code is not null and s.phase_code != 0
|
||||||
|
then s.phase_code
|
||||||
|
-- 2. Infer from statutory age range (independent schools still publish these)
|
||||||
|
when s.statutory_high_age is not null and s.statutory_high_age <= 11 then 2
|
||||||
|
when s.statutory_low_age is not null and s.statutory_low_age >= 11 then 4
|
||||||
|
when s.statutory_low_age is not null and s.statutory_high_age is not null
|
||||||
|
and s.statutory_low_age < 11 and s.statutory_high_age > 11 then 7
|
||||||
|
-- 3. Fallback: infer from school name (covers independents with missing ages)
|
||||||
|
when s.school_name ilike '%primary%'
|
||||||
|
or s.school_name ilike '%infant%'
|
||||||
|
or s.school_name ilike '%junior%'
|
||||||
|
or s.school_name ilike '%preparatory%'
|
||||||
|
or s.school_name ilike '% prep school%'
|
||||||
|
or s.school_name ilike '% prep %'
|
||||||
|
then 2
|
||||||
|
when s.school_name ilike '%secondary%'
|
||||||
|
or s.school_name ilike '%high school%'
|
||||||
|
or s.school_name ilike '%grammar%'
|
||||||
|
or s.school_name ilike '%senior school%'
|
||||||
|
or s.school_name ilike '%upper school%'
|
||||||
|
then 4
|
||||||
|
-- 4. Give up — null renders no phase pill
|
||||||
|
else null
|
||||||
|
end as phase_code,
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `s.school_type,` with `s.school_type_code,`; `s.religious_character,` with `s.religious_character_code,`; `s.admissions_policy,` with `s.admissions_policy_code,`; `s.status,` with `s.status_code,`.
|
||||||
|
|
||||||
|
Replace the has_sixth_form case with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- GIAS OfficialSixthForm in code space: 1 = has, 2 = does not, 0 = N/A.
|
||||||
|
-- Null (rare, new establishments) falls back to the statutory age range.
|
||||||
|
case
|
||||||
|
when s.official_sixth_form_code = 1 then true
|
||||||
|
when s.official_sixth_form_code in (0, 2) then false
|
||||||
|
else coalesce(s.statutory_high_age >= 18, false)
|
||||||
|
end as has_sixth_form,
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the status filter with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
|
||||||
|
-- GIAS flips to Closed — marts fully rebuild each run).
|
||||||
|
where s.status_code in (1, 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Same filter in dim_location.sql**
|
||||||
|
|
||||||
|
Replace its `where s.status in ('Open', 'Open, but proposed to close')` (and the comment above it) with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Must match dim_school's status filter exactly (the API inner-joins the two).
|
||||||
|
where s.status_code in (1, 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update _marts_schema.yml**
|
||||||
|
|
||||||
|
Under `dim_school` columns: rename `phase` → `phase_code` (keep the warn-severity not_null, reword description to mention codes); replace the `status` accepted_values block with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: status_code
|
||||||
|
description: GIAS EstablishmentStatus code (1 = Open, 3 = Open but proposed to close)
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
values: [1, 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add warn-severity accepted_values for the other codes, values copied from the seed (school_type/religious/admissions lists are long — paste the full code list from `gias_code_names.csv` for each):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: school_type_code
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
severity: warn
|
||||||
|
values: [<all school_type codes from the seed>]
|
||||||
|
- name: religious_character_code
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
severity: warn
|
||||||
|
values: [<all religious_character codes from the seed>]
|
||||||
|
- name: admissions_policy_code
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
severity: warn
|
||||||
|
values: [<all admissions_policy codes from the seed>]
|
||||||
|
```
|
||||||
|
|
||||||
|
(`<...>` here means: paste the actual comma-separated integers from the seed file — the lists exist by the time this task runs. Leaving a literal `<...>` in the yml is a task failure.)
|
||||||
|
|
||||||
|
`has_sixth_form` tests stay unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the drift test**
|
||||||
|
|
||||||
|
Create `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Warn when the live GIAS CSV carries a (code, name) pair we don't have in
|
||||||
|
-- the dictionary seed — i.e. DfE added or renamed a value. Fix by rerunning
|
||||||
|
-- pipeline/scripts/generate_gias_codes.py and committing the regenerated
|
||||||
|
-- dictionaries + seed together.
|
||||||
|
{{ config(severity='warn') }}
|
||||||
|
|
||||||
|
with raw_pairs as (
|
||||||
|
{% for field_key, code_col, name_col in [
|
||||||
|
('school_type', 'TypeOfEstablishment (code)', 'TypeOfEstablishment (name)'),
|
||||||
|
('establishment_status', 'EstablishmentStatus (code)', 'EstablishmentStatus (name)'),
|
||||||
|
('phase_of_education', 'PhaseOfEducation (code)', 'PhaseOfEducation (name)'),
|
||||||
|
('official_sixth_form', 'OfficialSixthForm (code)', 'OfficialSixthForm (name)'),
|
||||||
|
('religious_character', 'ReligiousCharacter (code)', 'ReligiousCharacter (name)'),
|
||||||
|
('admissions_policy', 'AdmissionsPolicy (code)', 'AdmissionsPolicy (name)')
|
||||||
|
] %}
|
||||||
|
select distinct
|
||||||
|
'{{ field_key }}' as field,
|
||||||
|
cast(nullif(trim("{{ code_col }}"), '') as integer) as code,
|
||||||
|
nullif(trim("{{ name_col }}"), '') as name
|
||||||
|
from {{ source('raw', 'gias_establishments') }}
|
||||||
|
where nullif(trim("{{ code_col }}"), '') is not null
|
||||||
|
and nullif(trim("{{ name_col }}"), '') is not null
|
||||||
|
{% if not loop.last %}union all{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
)
|
||||||
|
|
||||||
|
select r.*
|
||||||
|
from raw_pairs r
|
||||||
|
left join {{ ref('gias_code_names') }} s
|
||||||
|
on s.field = r.field
|
||||||
|
and s.code = r.code
|
||||||
|
and s.name = r.name
|
||||||
|
where s.field is null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify statically**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/tudor/projects/school_compare && \
|
||||||
|
uv run --with pyyaml python -c "import yaml; yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml')); print('yml OK')" && \
|
||||||
|
grep -c "_code" pipeline/transform/models/marts/dim_school.sql && \
|
||||||
|
grep -n "status_code in (1, 3)" pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql && \
|
||||||
|
grep -rn "s\.status\b\|s\.phase\b\|s\.school_type\b\|s\.religious_character\b\|s\.admissions_policy\b\|official_sixth_form\b" pipeline/transform/models/marts/dim_school.sql | grep -v "_code"; echo "stale-name grep exit=$? (want 1)"
|
||||||
|
```
|
||||||
|
Expected: `yml OK`, both filters matched, and no stale name-column references (final grep exits 1).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql pipeline/transform/models/marts/_marts_schema.yml pipeline/transform/tests/assert_gias_code_names_match_seed.sql
|
||||||
|
git commit -m "feat(pipeline): dim_school/dim_location store GIAS codes; seed drift test
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Backend translates at the API boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/models.py` (DimSchool columns)
|
||||||
|
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` + translation)
|
||||||
|
- Test: `backend/tests/test_gias_translation.py` (new)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `backend/gias_codes.py` dictionaries + `translate` (Task 1); mart code columns (Task 3).
|
||||||
|
- Produces: `translate_gias_code_columns(df) -> df` in `backend/data_loader.py`; after `load_school_data_as_dataframe()` the DataFrame carries today's name columns (`phase`, `school_type`, `status`, `religious_denomination`, `admissions_policy`) — every downstream consumer unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `backend/tests/test_gias_translation.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
|
||||||
|
rest of the backend sees must carry today's name strings."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backend.data_loader import translate_gias_code_columns
|
||||||
|
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
|
||||||
|
|
||||||
|
|
||||||
|
def _code_for(mapping, name):
|
||||||
|
return next(c for c, n in mapping.items() if n == name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codes_become_todays_names():
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
"urn": 1,
|
||||||
|
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
|
||||||
|
"school_type_code": np.nan,
|
||||||
|
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
|
||||||
|
"religious_character_code": np.nan,
|
||||||
|
"admissions_policy_code": np.nan,
|
||||||
|
}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
row = out.iloc[0]
|
||||||
|
assert row["phase"] == "Primary"
|
||||||
|
assert row["status"] == "Open, but proposed to close"
|
||||||
|
assert row["school_type"] is None
|
||||||
|
assert row["religious_denomination"] is None
|
||||||
|
assert row["admissions_policy"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_code_degrades_not_blanks():
|
||||||
|
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
assert out.iloc[0]["phase"] == "Unknown (9999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_code_columns_are_a_noop():
|
||||||
|
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
|
||||||
|
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
|
||||||
|
out = translate_gias_code_columns(df)
|
||||||
|
assert out.iloc[0]["phase"] == "Primary"
|
||||||
|
assert out.iloc[0]["status"] == "Open"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_translation.py -v`
|
||||||
|
Expected: FAIL — `ImportError: cannot import name 'translate_gias_code_columns'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement translation in data_loader.py**
|
||||||
|
|
||||||
|
Add near the top of `backend/data_loader.py` (after existing imports):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .gias_codes import (
|
||||||
|
ADMISSIONS_POLICY,
|
||||||
|
ESTABLISHMENT_STATUS,
|
||||||
|
PHASE_OF_EDUCATION,
|
||||||
|
RELIGIOUS_CHARACTER,
|
||||||
|
SCHOOL_TYPE,
|
||||||
|
translate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# mart code column -> (API name column, dictionary)
|
||||||
|
_GIAS_CODE_COLUMNS = {
|
||||||
|
"phase_code": ("phase", PHASE_OF_EDUCATION),
|
||||||
|
"school_type_code": ("school_type", SCHOOL_TYPE),
|
||||||
|
"status_code": ("status", ESTABLISHMENT_STATUS),
|
||||||
|
"religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER),
|
||||||
|
"admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Map GIAS code columns to today's name columns (API contract).
|
||||||
|
|
||||||
|
Runs immediately after pd.read_sql so every downstream consumer —
|
||||||
|
filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names.
|
||||||
|
DataFrames without the code columns (old schema, test fixtures) pass
|
||||||
|
through unchanged.
|
||||||
|
"""
|
||||||
|
for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items():
|
||||||
|
if code_col in df.columns:
|
||||||
|
df[name_col] = df[code_col].map(lambda c: translate(c, mapping))
|
||||||
|
return df
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Switch `_MAIN_QUERY` to code columns and call the translation**
|
||||||
|
|
||||||
|
In `_MAIN_QUERY` replace:
|
||||||
|
`s.phase,` → `s.phase_code,` · `s.school_type,` → `s.school_type_code,` · `s.religious_character AS religious_denomination,` → `s.religious_character_code,` · `s.admissions_policy,` → `s.admissions_policy_code,` · `s.status,` → `s.status_code,`
|
||||||
|
|
||||||
|
In `load_school_data_as_dataframe()`, insert the call immediately after the empty-check and **before** the existing `normalize_school_type` line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = translate_gias_code_columns(df)
|
||||||
|
|
||||||
|
# Build address string
|
||||||
|
...
|
||||||
|
# Normalize school type (existing line — now normalises the translated name)
|
||||||
|
df["school_type"] = df["school_type"].apply(normalize_school_type)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update DimSchool in models.py**
|
||||||
|
|
||||||
|
Replace `phase = Column(String(100))`, `school_type = Column(String(100))`, `religious_character = Column(String(100))`, `admissions_policy = Column(String(50))`, `status = Column(String(50))` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
phase_code = Column(Integer)
|
||||||
|
school_type_code = Column(Integer)
|
||||||
|
religious_character_code = Column(Integer)
|
||||||
|
admissions_policy_code = Column(Integer)
|
||||||
|
status_code = Column(Integer)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check nothing else references the removed attributes:
|
||||||
|
```bash
|
||||||
|
grep -rn "\.phase\b\|\.school_type\b\|\.religious_character\b\|\.admissions_policy\b\|\.status\b" backend/*.py | grep -i "dimschool\|DimSchool"
|
||||||
|
```
|
||||||
|
Expected: no hits (the backend reads via `_MAIN_QUERY`, not ORM attributes). If there are hits, update them to the `_code` columns + translation and note it in your report.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the new tests and the whole backend suite**
|
||||||
|
|
||||||
|
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v`
|
||||||
|
Expected: all pass — 3 new + all pre-existing (their fixtures carry name columns; translation is a no-op on them).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/models.py backend/data_loader.py backend/tests/test_gias_translation.py
|
||||||
|
git commit -m "feat(api): translate GIAS codes to names at the query boundary
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Typesense sync translates before indexing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/scripts/sync_typesense.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `pipeline/scripts/gias_codes.py` (Task 1), mart code columns (Task 3).
|
||||||
|
- Produces: identical Typesense documents to today (facet values are names).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Switch the SELECT and translate**
|
||||||
|
|
||||||
|
In `sync_typesense.py`: add at the top (the DAG runs `python scripts/sync_typesense.py`, so `scripts/` is `sys.path[0]` and a plain import works):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gias_codes import PHASE_OF_EDUCATION, RELIGIOUS_CHARACTER, SCHOOL_TYPE, translate
|
||||||
|
```
|
||||||
|
|
||||||
|
In the SQL, replace `s.phase,` → `s.phase_code,`, `s.school_type,` → `s.school_type_code,`, `s.religious_character,` → `s.religious_character_code,`.
|
||||||
|
|
||||||
|
In the document builder, replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"phase": row["phase"] or "",
|
||||||
|
"school_type": row["school_type"] or "",
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```python
|
||||||
|
"phase": translate(row["phase_code"], PHASE_OF_EDUCATION) or "",
|
||||||
|
"school_type": translate(row["school_type_code"], SCHOOL_TYPE) or "",
|
||||||
|
```
|
||||||
|
and:
|
||||||
|
```python
|
||||||
|
if row.get("religious_character"):
|
||||||
|
doc["religious_character"] = row["religious_character"]
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```python
|
||||||
|
religious_character = translate(row.get("religious_character_code"), RELIGIOUS_CHARACTER)
|
||||||
|
if religious_character:
|
||||||
|
doc["religious_character"] = religious_character
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify statically**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/tudor/projects/school_compare && \
|
||||||
|
python3 -c "import ast; ast.parse(open('pipeline/scripts/sync_typesense.py').read()); print('sync OK')" && \
|
||||||
|
grep -n "row\[\"phase\"\]\|row\[\"school_type\"\]\|row\[\"religious_character\"\]" pipeline/scripts/sync_typesense.py; echo "stale grep exit=$? (want 1)"
|
||||||
|
```
|
||||||
|
Expected: `sync OK`, no stale name-column row accesses.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/scripts/sync_typesense.py
|
||||||
|
git commit -m "feat(pipeline): typesense sync translates GIAS codes before indexing
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Spec status, PR, deploy runbook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md` (status line)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mark the spec implemented**
|
||||||
|
|
||||||
|
Change `**Status:** Approved design` to `**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md
|
||||||
|
git commit -m "docs: mark GIAS code dictionaries spec implemented
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||||
|
git push -u origin feat/gias-code-dictionaries
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Open the PR (Gitea API via git credential fill — token-header auth 401s)**
|
||||||
|
|
||||||
|
Title: `feat: GIAS classification fields stored as codes, translated in code`
|
||||||
|
Body must include: (1) API contract unchanged — names still served, translation at the query boundary; (2) the **deploy runbook: merge → deploy → trigger `school_data_daily` immediately** (accepted empty-API window until the marts rebuild — spec §7); (3) dictionary maintenance loop (dbt drift test warns → rerun `generate_gias_codes.py` → commit regenerated files); (4) no frontend/e2e changes. End with the standard generation footer.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Watch CI**
|
||||||
|
|
||||||
|
All PR checks must pass. Do not merge — merging triggers the deploy window; the human runs the runbook.
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
# Compare-Screen Data Foundation (Pipeline PR) Implementation 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:** Land every pipeline/dbt change the compare-screen redesign needs (spec §5 + §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`): promote raw-but-unstored fields to marts, close the national-averages gaps, and wire the Ofsted report-card columns.
|
||||||
|
|
||||||
|
**Architecture:** Meltano Singer taps load `raw.*` tables; dbt builds `staging` → `marts` (read-only for the backend). All changes here are additive columns/rows — no breaking changes to existing marts. The full `dbt build` runs on the server via the Airflow DAGs; locally we gate with `dbt parse` (no DB needed) plus network-only diagnostic scripts.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (Singer SDK taps), dbt-postgres ~1.10 (invoked as `python -m dbt.cli.main`), Meltano, PostgreSQL.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **No new external sources** (spec §5): only fields already in the `raw` schema or in files the taps already download. The one sanctioned tap change is the Ofsted MI report-card columns (spec §5, §8.4) and the legacy-KS2 year addition (same DfE performance-tables source).
|
||||||
|
- **Additive only:** never rename or drop existing mart columns; the backend maps them 1:1 in `backend/models.py`.
|
||||||
|
- **Never push to `main`.** Branch: `feat/compare-data-foundation`; PR checks must pass.
|
||||||
|
- Backend `models.py` changes belong to the follow-up backend PR, not this one.
|
||||||
|
- dbt invocation is always `python -m dbt.cli.main` (a bare `dbt` resolves to the wrong binary — see `pipeline/dags/school_data_pipeline.py:27`).
|
||||||
|
- EES suppression codes `z`/`c`/`x` must go through the `safe_numeric` macro.
|
||||||
|
- Computed benchmarks (FSM/EAL/SEN medians, disadvantaged national average) are **backend work** (spec §5) — explicitly out of scope here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Create the branch
|
||||||
|
|
||||||
|
**Files:** none
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `git checkout main && git pull && git checkout -b feat/compare-data-foundation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Diagnostics — pin the three unknowns
|
||||||
|
|
||||||
|
The spec flags three facts we must confirm from the actual files before wiring code: (a) why `gps_expected_pct`/`science_expected_pct` are NULL in `marts.fact_ks2_national_averages` despite being mapped end-to-end; (b) what the KS2 attainment long file calls its subjects/years for 2021/22 and 2022/23 (subject-level 2022/23 is NULL in prod; school-level 2021/22 is absent); (c) the exact report-card column headers in the current Ofsted MI CSV.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pipeline/scripts/diagnose_compare_gaps.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: a printed findings report; Tasks 5, 6, 7 consume the confirmed column/label names. Precedent: `pipeline/scripts/diagnose_ees_ks4.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the diagnostic script**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Diagnose the three data gaps blocking the compare-screen redesign.
|
||||||
|
|
||||||
|
Run from repo root (network access required, no DB needed):
|
||||||
|
python pipeline/scripts/diagnose_compare_gaps.py
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ees")
|
||||||
|
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ofsted")
|
||||||
|
from tap_uk_ees.tap import ( # noqa: E402
|
||||||
|
_KS2_NATIONAL_COL_MAP,
|
||||||
|
_KS2_NATIONAL_CSV_URL,
|
||||||
|
download_release_zip,
|
||||||
|
get_all_releases,
|
||||||
|
)
|
||||||
|
from tap_uk_ofsted.tap import discover_csv_url # noqa: E402
|
||||||
|
|
||||||
|
TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def check_national_gps_science():
|
||||||
|
print("\n=== (a) National catalogue CSV: GPS/science columns ===")
|
||||||
|
resp = requests.get(_KS2_NATIONAL_CSV_URL, timeout=TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||||
|
df.columns = [c.strip().lower() for c in df.columns]
|
||||||
|
for csv_col in ("pt_gps_exp", "pt_scita_exp", "avg_readscore", "avg_matscore", "avg_gpsscore"):
|
||||||
|
status = "PRESENT" if csv_col in df.columns else "MISSING"
|
||||||
|
print(f" {csv_col}: {status}")
|
||||||
|
gps_like = [c for c in df.columns if "gps" in c or "scita" in c or "sci" in c]
|
||||||
|
print(f" all gps/science-ish columns: {gps_like}")
|
||||||
|
nat = df[df.get("geographic_level", "").str.strip().str.lower() == "national"]
|
||||||
|
print(f" national rows time_periods: {sorted(nat['time_period'].unique())}")
|
||||||
|
# Sample the values our map would read for the latest year
|
||||||
|
latest = nat[nat["time_period"] == nat["time_period"].max()]
|
||||||
|
for csv_col, field in _KS2_NATIONAL_COL_MAP.items():
|
||||||
|
val = latest.iloc[0].get(csv_col, "<col missing>") if len(latest) else "<no row>"
|
||||||
|
print(f" {field} <- {csv_col} = {val!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_ks2_attainment_years_subjects():
|
||||||
|
print("\n=== (b) EES KS2 attainment: years & subject labels ===")
|
||||||
|
releases = get_all_releases("key-stage-2-attainment")
|
||||||
|
print(f" releases found: {[r['time_period'] for r in releases]}")
|
||||||
|
for release in releases:
|
||||||
|
zf = download_release_zip(release["id"])
|
||||||
|
name = next((n for n in zf.namelist()
|
||||||
|
if "ks2_school_attainment_data" in n and n.endswith(".csv")), None)
|
||||||
|
if not name:
|
||||||
|
print(f" {release['time_period']}: NO school attainment CSV in ZIP")
|
||||||
|
continue
|
||||||
|
with zf.open(name) as f:
|
||||||
|
df = pd.read_csv(f, dtype=str, keep_default_na=False, nrows=200000)
|
||||||
|
years = sorted(df["time_period"].unique())
|
||||||
|
subjects = sorted(df["subject"].unique())
|
||||||
|
print(f" release {release['time_period']}: time_periods={years}")
|
||||||
|
print(f" subjects={subjects}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_ofsted_report_card_columns():
|
||||||
|
print("\n=== (c) Ofsted MI CSV: report-card columns ===")
|
||||||
|
url = discover_csv_url()
|
||||||
|
print(f" MI file: {url}")
|
||||||
|
resp = requests.get(url, timeout=TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False, nrows=5)
|
||||||
|
rc_like = [c for c in df.columns
|
||||||
|
if re.search(r"report card|inclusion|curriculum|achievement|safeguard|well.?being|governance", c, re.I)]
|
||||||
|
print(f" candidate report-card columns ({len(rc_like)}):")
|
||||||
|
for c in rc_like:
|
||||||
|
print(f" - {c!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_national_gps_science()
|
||||||
|
check_ks2_attainment_years_subjects()
|
||||||
|
check_ofsted_report_card_columns()
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: if `_KS2_NATIONAL_CSV_URL` is named differently in `tap_uk_ees/tap.py` (it is defined near the `_KS2_NATIONAL_COL_MAP` around line ~490), import whatever constant holds the catalogue CSV URL.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it and record findings**
|
||||||
|
|
||||||
|
Run: `python pipeline/scripts/diagnose_compare_gaps.py 2>&1 | tee /tmp/compare-gaps-findings.txt`
|
||||||
|
Expected: three sections printed. Paste the findings as a comment block at the bottom of the script (so they're committed evidence), e.g. `# FINDINGS 2026-07-12: pt_gps_exp MISSING (actual col: ...), 202122 present in release X, rc columns: [...]`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/scripts/diagnose_compare_gaps.py
|
||||||
|
git commit -m "chore(pipeline): diagnostic for compare-screen data gaps"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Admissions preference detail → mart
|
||||||
|
|
||||||
|
Staging already extracts `second_preference_offers`, `third_preference_offers`, `total_offers` (`stg_ees_admissions.sql:26-29`) — the mart drops them. The cross-LA fields are declared in the tap (`all_applications_from_another_LA`, `offers_to_applicants_from_another_LA`) but not selected in staging.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ees_admissions.sql` (after line 33, in `renamed`)
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_admissions.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_admissions block, ~line 120)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces mart columns: `total_offers int`, `second_preference_offers int`, `third_preference_offers int`, `cross_la_applications int`, `cross_la_offers int`. The backend PR will map these in `FactAdmissions`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add cross-LA columns to staging**
|
||||||
|
|
||||||
|
In `stg_ees_admissions.sql`, after the `first_preference_applications` line (line 33):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Cross-borough demand: applications naming this school from families
|
||||||
|
-- living in another local authority, and offers made to them.
|
||||||
|
{{ safe_numeric('"all_applications_from_another_LA"') }}::integer as cross_la_applications,
|
||||||
|
{{ safe_numeric('"offers_to_applicants_from_another_LA"') }}::integer as cross_la_offers,
|
||||||
|
```
|
||||||
|
|
||||||
|
(Quote the identifiers — the tap emits them with mixed case, same trap as `FSM_eligible_percent`, see the header comment in that file. If `dbt parse` or the DAG run later shows the raw columns are lower-cased in Postgres, drop the double quotes.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Pass everything through the mart**
|
||||||
|
|
||||||
|
Replace the full select list in `fact_admissions.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Mart: School admissions — one row per URN per year
|
||||||
|
|
||||||
|
select
|
||||||
|
urn,
|
||||||
|
year,
|
||||||
|
school_phase,
|
||||||
|
places_offered,
|
||||||
|
total_offers,
|
||||||
|
total_applications,
|
||||||
|
first_preference_applications,
|
||||||
|
first_preference_offers,
|
||||||
|
second_preference_offers,
|
||||||
|
third_preference_offers,
|
||||||
|
cross_la_applications,
|
||||||
|
cross_la_offers,
|
||||||
|
first_preference_offer_pct,
|
||||||
|
oversubscription_ratio,
|
||||||
|
oversubscribed,
|
||||||
|
admissions_policy
|
||||||
|
from {{ ref('stg_ees_admissions') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add schema tests**
|
||||||
|
|
||||||
|
In `_marts_schema.yml` under `fact_admissions.columns`, append:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: second_preference_offers
|
||||||
|
- name: third_preference_offers
|
||||||
|
- name: cross_la_applications
|
||||||
|
- name: cross_la_offers
|
||||||
|
- name: total_offers
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Parse gate**
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
|
||||||
|
Expected: `Done.` with no compilation errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/staging/stg_ees_admissions.sql pipeline/transform/models/marts/fact_admissions.sql pipeline/transform/models/marts/_marts_schema.yml
|
||||||
|
git commit -m "feat(pipeline): admissions preference breakdown and cross-LA demand in marts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: KS2 progress confidence intervals + writing working-towards
|
||||||
|
|
||||||
|
The tap already emits `progress_measure_lower_conf_interval`, `progress_measure_upper_conf_interval`, `working_towards_expected_standard_pupil_percent` (tap.py:203-206). The staging pivot drops them. These power the CI-based Above/Average/Below progress chips (spec §8, first-review item on statistical honesty).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ees_ks2.sql` (inside the `pivoted` CTE, next to each subject's `progress_measure_score` case, lines ~41/55/72, and in the final select ~lines 145-152)
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ks2_performance.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks2_performance block, ~line 82)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces mart columns: `reading_progress_lower_ci`, `reading_progress_upper_ci`, `writing_progress_lower_ci`, `writing_progress_upper_ci`, `maths_progress_lower_ci`, `maths_progress_upper_ci` (float), `writing_working_towards_pct` (float).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add pivot cases in staging**
|
||||||
|
|
||||||
|
After the `reading_progress` case (line ~41), add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
max(case when subject = 'Reading'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as reading_progress_lower_ci,
|
||||||
|
max(case when subject = 'Reading'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as reading_progress_upper_ci,
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `writing_progress` case (line ~55), add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
max(case when subject = 'Writing'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as writing_progress_lower_ci,
|
||||||
|
max(case when subject = 'Writing'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as writing_progress_upper_ci,
|
||||||
|
max(case when subject = 'Writing'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('working_towards_expected_standard_pupil_percent') }} end) as writing_working_towards_pct,
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `maths_progress` case (line ~72), add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
max(case when subject = 'Maths'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as maths_progress_lower_ci,
|
||||||
|
max(case when subject = 'Maths'
|
||||||
|
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||||
|
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as maths_progress_upper_ci,
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the seven new columns to the model's final select (next to the existing `p.reading_progress` / `p.writing_progress` / `p.maths_progress` lines ~145-152):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
p.reading_progress_lower_ci,
|
||||||
|
p.reading_progress_upper_ci,
|
||||||
|
p.writing_progress_lower_ci,
|
||||||
|
p.writing_progress_upper_ci,
|
||||||
|
p.writing_working_towards_pct,
|
||||||
|
p.maths_progress_lower_ci,
|
||||||
|
p.maths_progress_upper_ci,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Pass through the mart**
|
||||||
|
|
||||||
|
In `fact_ks2_performance.sql`, add the same seven column names to the select list immediately after the existing `maths_progress` line (this mart selects staging columns by name; match the file's existing alias style — if columns are selected bare, add them bare).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Schema tests**
|
||||||
|
|
||||||
|
In `_marts_schema.yml` under `fact_ks2_performance.columns`, append the seven names (no tests beyond presence — values are legitimately NULL for 2023/24+ since progress measures ended with 2022/23, spec §4.3):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: reading_progress_lower_ci
|
||||||
|
- name: reading_progress_upper_ci
|
||||||
|
- name: writing_progress_lower_ci
|
||||||
|
- name: writing_progress_upper_ci
|
||||||
|
- name: writing_working_towards_pct
|
||||||
|
- name: maths_progress_lower_ci
|
||||||
|
- name: maths_progress_upper_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Parse gate**
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
|
||||||
|
Expected: `Done.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/staging/stg_ees_ks2.sql pipeline/transform/models/marts/fact_ks2_performance.sql pipeline/transform/models/marts/_marts_schema.yml
|
||||||
|
git commit -m "feat(pipeline): KS2 progress confidence intervals and writing working-towards"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: KS4 — Progress 8 banding and disadvantage gaps
|
||||||
|
|
||||||
|
The tap's `ees_ks4_info` stream already declares `progress8_banding` (DfE's own "well above average … well below average" label — the ready-made secondary chip), `attainment8_diffn` and `progress8_diffn` (tap.py:338-340). Wire them through staging into the mart.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ees_ks4.sql` (the CTE that reads `ees_ks4_info` — the same one that already surfaces `sen_pct`; add three columns to its select and to the final joined select)
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ks4_performance.sql` (add after `progress_8_upper_ci`)
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks4_performance block, ~line 93)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces mart columns: `progress_8_banding text`, `attainment_8_disadvantage_gap float`, `progress_8_disadvantage_gap float`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Staging — select from the info source**
|
||||||
|
|
||||||
|
In the info CTE of `stg_ees_ks4.sql` add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
nullif(trim(progress8_banding), '') as progress_8_banding,
|
||||||
|
{{ safe_numeric('attainment8_diffn') }} as attainment_8_disadvantage_gap,
|
||||||
|
{{ safe_numeric('progress8_diffn') }} as progress_8_disadvantage_gap,
|
||||||
|
```
|
||||||
|
|
||||||
|
and add the three names to the model's final select (aliased the same way the CTE's other columns are).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Mart passthrough**
|
||||||
|
|
||||||
|
In `fact_ks4_performance.sql`, after the `progress_8_upper_ci,` line:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
progress_8_banding,
|
||||||
|
attainment_8_disadvantage_gap,
|
||||||
|
progress_8_disadvantage_gap,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Schema tests** — append the three names under `fact_ks4_performance.columns`, plus an accepted-values guard that tolerates NULL:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: progress_8_banding
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
values: ['Well above average', 'Above average', 'Average', 'Below average', 'Well below average']
|
||||||
|
config:
|
||||||
|
where: "progress_8_banding is not null"
|
||||||
|
- name: attainment_8_disadvantage_gap
|
||||||
|
- name: progress_8_disadvantage_gap
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the DAG run later shows different capitalisation in the data, fix the accepted values to match the data, not vice versa.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Parse gate** — `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .` → `Done.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/staging/stg_ees_ks4.sql pipeline/transform/models/marts/fact_ks4_performance.sql pipeline/transform/models/marts/_marts_schema.yml
|
||||||
|
git commit -m "feat(pipeline): Progress 8 banding and KS4 disadvantage gaps in marts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: National averages — 2015/16 row and GPS/science/scaled-score fix
|
||||||
|
|
||||||
|
Two changes. (1) `stg_ees_ks2_national.sql:34` filters `>= 201617`, which is exactly why the England line starts a year late (2015/16 RWM = 53% exists in the catalogue). (2) GPS/science expected are NULL in prod despite full end-to-end mapping — Task 1's findings say whether the catalogue CSV column names differ from `_KS2_NATIONAL_COL_MAP` (`pt_gps_exp`, `pt_scita_exp`) or whether values are suppressed at source.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ees_ks2_national.sql:34`
|
||||||
|
- Modify (conditional on Task 1 findings): `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (`_KS2_NATIONAL_COL_MAP`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: a 201516 row in `marts.fact_ks2_national_averages`; non-NULL `gps_expected_pct`, `science_expected_pct`, `reading_avg_score`, `maths_avg_score`, `gps_avg_score` for years the DfE publishes them. Backend/frontend consume via `/api/national-averages` unchanged (additive year + newly non-NULL fields).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Widen the year filter**
|
||||||
|
|
||||||
|
In `stg_ees_ks2_national.sql`, change line 34:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
and cast(trim(time_period) as integer) >= 201516
|
||||||
|
```
|
||||||
|
|
||||||
|
(2015/16 was the first year of the current expected-standard tests; nothing earlier is comparable, so keep a floor.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix the column map per Task 1 findings**
|
||||||
|
|
||||||
|
If Task 1 reported the actual CSV column names for GPS/science/scaled scores differ, update `_KS2_NATIONAL_COL_MAP` in `tap.py` accordingly, e.g. (illustrative — use the diagnosed names):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_KS2_NATIONAL_COL_MAP = {
|
||||||
|
# ... existing entries ...
|
||||||
|
"pt_gps_exp": "gps_expected_pct", # replace key with diagnosed name
|
||||||
|
"pt_scita_exp": "science_expected_pct", # replace key with diagnosed name
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If Task 1 showed the columns are present but suppressed (`x`) at national level for all years, instead delete the two entries from the map, delete the corresponding lines from `stg_ees_ks2_national.sql` and `fact_ks2_national_averages.sql`, and record in the PR description that GPS/science England ticks stay "not in dataset" (the mockups already carry that caveat).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Parse gate** — `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .` → `Done.`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/staging/stg_ees_ks2_national.sql pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py
|
||||||
|
git commit -m "fix(pipeline): include 2015/16 national averages; fix GPS/science national mapping"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Legacy KS2 — load the 2021/22 school-level year
|
||||||
|
|
||||||
|
School-level 2021/22 exists in DfE performance-tables archives (same source as the four legacy years already loaded) but in neither our legacy config (stops at 201819, `pipeline/meltano.yml:33-37`) nor EES (starts 2022/23) — unless Task 1's finding (b) showed an EES release carrying 202122, in which case skip this task and note why in the PR.
|
||||||
|
|
||||||
|
The legacy URLs point at the self-hosted filebrowser (`10.0.1.224:8081`) — **the 2021/22 DfE archive must be uploaded there first; this is the one human dependency in this plan.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/meltano.yml` (legacy_ks2_urls block, line ~33)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `raw.legacy_ks2` rows with `year = '202122'`, flowing through `stg_legacy_ks2` → `fact_ks2_performance` unchanged (the stream maps old column names already; 2021/22 CSVs use the same `PTRWM_EXP`-style headers as 2018/19).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify the 2021/22 CSV headers match `_LEGACY_KS2_COLUMN_MAP`**
|
||||||
|
|
||||||
|
Download the DfE 2021/22 KS2 revised archive (gov.uk "Compare School Performance data download": 2021-2022 all-schools ZIP), then:
|
||||||
|
|
||||||
|
Run: `python -c "import zipfile,io,pandas as pd; zf=zipfile.ZipFile('/path/to/2021-2022.zip'); n=[x for x in zf.namelist() if 'ks2final' in x.lower() and x.endswith('.csv')][0]; df=pd.read_csv(zf.open(n), dtype=str, nrows=5); import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ees'); from tap_uk_ees.tap import _LEGACY_KS2_COLUMN_MAP as m; missing=[c for c in m if c not in df.columns]; print('missing legacy columns:', missing)"`
|
||||||
|
Expected: `missing legacy columns: []` (progress columns `READPROG` etc. may legitimately be missing/blank in 2021/22 — acceptable, they load as NULL).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Upload the archive to the filebrowser and add the config entry**
|
||||||
|
|
||||||
|
In `pipeline/meltano.yml` under `legacy_ks2_urls`, add (with the real share URL from the filebrowser upload):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
"202122": "http://10.0.1.224:8081/filebrowser/api/public/dl/<SHARE_ID>?inline=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/meltano.yml
|
||||||
|
git commit -m "feat(pipeline): load 2021/22 school-level KS2 from legacy performance tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 (only if Task 1(b) showed 2022/23 subject labels differ):** widen the subject matchers in `stg_ees_ks2.sql` the same way GPS already is (`subject ilike '%grammar%' or subject = 'GPS'`), e.g. `subject in ('Reading', 'reading')` → use the diagnosed labels. Parse-gate and commit as `fix(pipeline): match 2022/23 KS2 subject labels`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Ofsted report-card columns (rc_*)
|
||||||
|
|
||||||
|
Resolves the tap TODO (`stg_ofsted_inspections.sql:37`). The marts/backed columns already exist as stubs; this wires real values. Uses Task 1(c)'s confirmed MI column names — the candidates below follow the MI file's existing naming style and must be corrected against the diagnostic output.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY ~line 19-72, schema ~line 100-114)
|
||||||
|
- Create: `pipeline/transform/macros/parse_report_card_grade.sql`
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql:36-46`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces mart columns (already declared in `fact_ofsted_inspection`): `rc_safeguarding_met boolean`, and `rc_inclusion` … `rc_sixth_form` as integers on the 5-point scale `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention/Attention needed, 5=Urgent improvement`. The backend translates codes to labels (same pattern as `gias_codes.py`), verifying wording against Ofsted's published toolkit (spec §8.4).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add tap column mappings**
|
||||||
|
|
||||||
|
In `COLUMN_PRIORITY` add (replace candidate strings with Task 1(c)'s exact headers — keep them as priority lists so older files degrade to blank):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"rc_safeguarding_met": ["Report card safeguarding", "Safeguarding"],
|
||||||
|
"rc_inclusion": ["Report card inclusion", "Inclusion"],
|
||||||
|
"rc_curriculum_teaching": ["Report card curriculum and teaching", "Curriculum and teaching"],
|
||||||
|
"rc_achievement": ["Report card achievement", "Achievement"],
|
||||||
|
"rc_attendance_behaviour": ["Report card attendance and behaviour", "Attendance and behaviour"],
|
||||||
|
"rc_personal_development": ["Report card personal development and well-being", "Personal development and well-being"],
|
||||||
|
"rc_leadership_governance": ["Report card leadership and governance", "Leadership and governance"],
|
||||||
|
"rc_early_years": ["Report card early years", "Early years"],
|
||||||
|
"rc_sixth_form": ["Report card sixth form", "Sixth form"],
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the stream schema (next to `report_url`, ~line 114):
|
||||||
|
|
||||||
|
```python
|
||||||
|
th.Property("rc_safeguarding_met", th.StringType),
|
||||||
|
th.Property("rc_inclusion", th.StringType),
|
||||||
|
th.Property("rc_curriculum_teaching", th.StringType),
|
||||||
|
th.Property("rc_achievement", th.StringType),
|
||||||
|
th.Property("rc_attendance_behaviour", th.StringType),
|
||||||
|
th.Property("rc_personal_development", th.StringType),
|
||||||
|
th.Property("rc_leadership_governance", th.StringType),
|
||||||
|
th.Property("rc_early_years", th.StringType),
|
||||||
|
th.Property("rc_sixth_form", th.StringType),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the grade-parsing macro**
|
||||||
|
|
||||||
|
`pipeline/transform/macros/parse_report_card_grade.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{% macro parse_report_card_grade(column_name) %}
|
||||||
|
case lower(trim(nullif({{ column_name }}, 'NULL')))
|
||||||
|
when 'exceptional' then 1
|
||||||
|
when 'strong standard' then 2
|
||||||
|
when 'expected standard' then 3
|
||||||
|
when 'needs attention' then 4
|
||||||
|
when 'attention needed' then 4
|
||||||
|
when 'urgent improvement' then 5
|
||||||
|
end
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire staging**
|
||||||
|
|
||||||
|
Replace `stg_ofsted_inspections.sql` lines 36-46 (the NULL stubs) with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Report Card fields (post-Nov 2025 framework), 5-point scale:
|
||||||
|
-- 1 Exceptional · 2 Strong standard · 3 Expected standard
|
||||||
|
-- · 4 Needs attention · 5 Urgent improvement
|
||||||
|
(lower(trim(nullif(rc_safeguarding_met, 'NULL'))) = 'met') as rc_safeguarding_met,
|
||||||
|
{{ parse_report_card_grade('rc_inclusion') }}::integer as rc_inclusion,
|
||||||
|
{{ parse_report_card_grade('rc_curriculum_teaching') }}::integer as rc_curriculum_teaching,
|
||||||
|
{{ parse_report_card_grade('rc_achievement') }}::integer as rc_achievement,
|
||||||
|
{{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour,
|
||||||
|
{{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development,
|
||||||
|
{{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance,
|
||||||
|
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
|
||||||
|
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `rc_safeguarding_met` becomes boolean (NULL when blank) — matching `fact_ofsted_inspection`'s `rc_safeguarding_met` Boolean column. If `fact_ofsted_inspection.sql` casts these columns, align its casts too (inspect that model; it currently passes the text stubs through).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Parse gate + tap smoke test**
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .` → `Done.`
|
||||||
|
Run: `python -c "import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ofsted'); from tap_uk_ofsted.tap import COLUMN_PRIORITY; assert 'rc_inclusion' in COLUMN_PRIORITY; print('ok')"` → `ok`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py pipeline/transform/macros/parse_report_card_grade.sql pipeline/transform/models/staging/stg_ofsted_inspections.sql
|
||||||
|
git commit -m "feat(pipeline): extract Ofsted report-card judgements (rc_* columns)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: PR + post-merge verification
|
||||||
|
|
||||||
|
**Files:** none new
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push and open the PR** (Gitea — use the git credential helper + basic-auth API pattern; token-header auth 401s):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/compare-data-foundation
|
||||||
|
# then create the PR via the Gitea API with basic auth from `git credential fill`
|
||||||
|
```
|
||||||
|
|
||||||
|
PR body: link spec §5/§8, list the new mart columns, note the Task 6 human dependency (filebrowser upload) and the Task 1 findings file.
|
||||||
|
|
||||||
|
- [ ] **Step 2: After merge, verify the DAG run picked everything up**
|
||||||
|
|
||||||
|
The daily/monthly DAGs rebuild the affected models (`pipeline/dags/school_data_pipeline.py`). Spot-check via the public API (production after promotion, staging first at stx.schoolcompare.co.uk — note external /api is broken at the staging proxy, so check staging from the host):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 2015/16 national row exists
|
||||||
|
curl -sL "https://www.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(r['year']==201516 and r['primary'] for r in d['by_year']), '2015/16 missing'; print('201516 ok')"
|
||||||
|
# 2021/22 school rows exist (Barclay)
|
||||||
|
curl -sL "https://www.schoolcompare.co.uk/api/schools/138690" | python3 -c "import json,sys; d=json.load(sys.stdin); ys=[r['year'] for r in d['yearly_data']]; assert 202122 in [int(y) for y in ys], ys; print('202122 ok')"
|
||||||
|
```
|
||||||
|
|
||||||
|
(The admissions/CI/KS4/rc_* columns aren't API-visible until the backend PR maps them — verify those directly in Postgres from the pipeline host: `select count(*) from marts.fact_admissions where second_preference_offers is not null;` etc.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the spec** — tick off the §5 promotions this PR delivered (edit the spec's promotion list to note "landed in PR #NN") and commit to main via a docs PR or alongside the backend PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (next plans)
|
||||||
|
|
||||||
|
1. **Backend PR:** map new columns in `backend/models.py`, extend `/api/compare` with supplementary blocks + `national_averages`, computed benchmarks (FSM/EAL/SEN/size medians, disadvantaged national average), CI-based progress banding, report-card label translation (verify against Ofsted toolkit), Ofsted provider-page URLs, graded-vs-ungraded surfacing.
|
||||||
|
2. **Frontend PR:** rebuild `/compare` per the mockups + e2e journeys (promotion gate).
|
||||||
|
3. **Separate bug fix:** third school's series not rendering on the current production chart.
|
||||||
|
4. **Post-v1 (spec):** census ethnicity/young-carer promotion, IDACI display, attendance section, gender-split/absence tier-2 measures.
|
||||||
|
5. **Already in marts, no work needed:** KS4 EBacc entry/APS, grade 5+ English & maths, Progress 8 CIs — `fact_ks4_performance` carries them today; only the backend needs to expose them.
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
# Compare API Enrichment (Backend PR) Implementation 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:** Expose the PR #32 data through the API so the redesigned compare screen can be built: enrich `/api/compare` with supplementary blocks + national averages + computed benchmarks, translate Ofsted report-card codes to labels, and surface the new mart columns (spec §6, §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`).
|
||||||
|
|
||||||
|
**Architecture:** All changes are additive API fields — existing consumers keep working. One small dbt change rides along: `fact_performance` (the combined KS2+KS4 mart the backend's `_MAIN_QUERY` reads) enumerates columns explicitly and was not extended in PR #32, so the new KS2 CI and KS4 banding columns must be threaded through it here. Everything else is backend Python: `models.py` mappings, `data_loader` query/supplementary additions, an Ofsted label dictionary (gias_codes pattern), and `/api/compare` composition.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, SQLAlchemy, pandas; dbt (one model); pytest via `python -m pytest backend/tests -q` (CI installs `requirements.txt pytest "httpx<0.28"`; locally use `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Never push to `main`.** Branch: `feat/compare-api-enrichment`.
|
||||||
|
- **Additive only** to API responses; never rename/remove existing fields (frontend + e2e depend on them).
|
||||||
|
- **Report-card scale labels are the live-sampled vocabulary** (evidence in `pipeline/scripts/diagnose_compare_gaps.py`): `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention, 5=Urgent improvement`. Never "Attention needed". Safeguarding is boolean met/not-met, never counted as a graded area.
|
||||||
|
- **Ofsted links** are always the provider page `https://reports.ofsted.gov.uk/provider/21/{urn}` (spec §5) labelled as the school's Ofsted page.
|
||||||
|
- **Benchmark provenance** (spec §8.6): computed values are "state-school average (computed from our dataset)" — the API must expose them under a `benchmarks` key, clearly separate from official `national_averages`.
|
||||||
|
- TDD: each behaviour lands with a failing test first, in `backend/tests/` following the `test_school_details.py` pattern (pandas fixture + monkeypatched `load_school_data` + `TestClient`).
|
||||||
|
- Deploy note for the PR body: the new API fields return NULL/empty until prod's DAGs have run post-promotion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Branch
|
||||||
|
|
||||||
|
- [ ] `git checkout main && git pull && git checkout -b feat/compare-api-enrichment` (commit this plan file on the branch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Thread PR #32 columns through `fact_performance`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_performance.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_performance block, if it has one — add the columns wherever the model's other columns are listed; if the model has no column list there, skip the yml)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (for `_MAIN_QUERY` in Task 4): `ks2.*` CI columns and `ks4.progress_8_banding`, `ks4.attainment_8_disadvantage_gap`, `ks4.progress_8_disadvantage_gap` on `marts.fact_performance`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `fact_performance.sql`, after `ks2.reading_progress,` add `ks2.reading_progress_lower_ci,` and `ks2.reading_progress_upper_ci,`; after `ks2.writing_progress,` add `ks2.writing_progress_lower_ci,`, `ks2.writing_progress_upper_ci,`, `ks2.writing_working_towards_pct,`; after `ks2.maths_progress,` add `ks2.maths_progress_lower_ci,`, `ks2.maths_progress_upper_ci,`. In the KS4 section, after the `ks4.progress_8_upper_ci`-equivalent line (locate the Progress 8 block) add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ks4.progress_8_banding,
|
||||||
|
ks4.attainment_8_disadvantage_gap,
|
||||||
|
ks4.progress_8_disadvantage_gap,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Parse gate: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` → exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat(pipeline): thread compare-foundation columns through fact_performance`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: ORM mappings for the new mart columns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/models.py` (`KS2Performance` after `maths_progress`; `FactAdmissions` after `first_preference_offers`)
|
||||||
|
- Test: none (declarative mappings; covered by Task 4's query tests)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces attributes used by Task 4: `KS2Performance.reading_progress_lower_ci` … `maths_progress_upper_ci`, `writing_working_towards_pct` (Float); `FactAdmissions.total_offers`, `.second_preference_offers`, `.third_preference_offers`, `.cross_la_applications`, `.cross_la_offers` (Integer).
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add to `KS2Performance` (next to the existing progress columns):
|
||||||
|
|
||||||
|
```python
|
||||||
|
reading_progress_lower_ci = Column(Float)
|
||||||
|
reading_progress_upper_ci = Column(Float)
|
||||||
|
writing_progress_lower_ci = Column(Float)
|
||||||
|
writing_progress_upper_ci = Column(Float)
|
||||||
|
writing_working_towards_pct = Column(Float)
|
||||||
|
maths_progress_lower_ci = Column(Float)
|
||||||
|
maths_progress_upper_ci = Column(Float)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `FactAdmissions` (after `first_preference_offers`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
total_offers = Column(Integer)
|
||||||
|
second_preference_offers = Column(Integer)
|
||||||
|
third_preference_offers = Column(Integer)
|
||||||
|
cross_la_applications = Column(Integer)
|
||||||
|
cross_la_offers = Column(Integer)
|
||||||
|
```
|
||||||
|
|
||||||
|
(`FactOfstedInspection` already maps all `rc_*` columns with the right types — verify, don't change.)
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Commit: `feat(api): map compare-foundation mart columns`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Ofsted label dictionary + provider URL (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/ofsted_codes.py`
|
||||||
|
- Test: `backend/tests/test_ofsted_codes.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces for Task 4: `REPORT_CARD_GRADE_NAMES: dict[int, str]`, `report_card_labels(ofsted: dict) -> dict` (returns `{area_key: {"code": int, "label": str}}` for the non-null `rc_*` grade fields, excluding safeguarding), `ofsted_page_url(urn: int) -> str`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Report-card code translation uses the live-sampled Ofsted vocabulary
|
||||||
|
(pipeline/scripts/diagnose_compare_gaps.py TASK 7 VALUE SAMPLE):
|
||||||
|
Exceptional / Strong standard / Expected standard / Needs attention /
|
||||||
|
Urgent improvement — never the consultation draft's 'Attention needed'."""
|
||||||
|
from backend.ofsted_codes import (
|
||||||
|
REPORT_CARD_GRADE_NAMES, report_card_labels, ofsted_page_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_is_sampled_vocabulary():
|
||||||
|
assert REPORT_CARD_GRADE_NAMES == {
|
||||||
|
1: "Exceptional",
|
||||||
|
2: "Strong standard",
|
||||||
|
3: "Expected standard",
|
||||||
|
4: "Needs attention",
|
||||||
|
5: "Urgent improvement",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_only_for_populated_areas_and_never_safeguarding():
|
||||||
|
ofsted = {
|
||||||
|
"rc_achievement": 2,
|
||||||
|
"rc_inclusion": 3,
|
||||||
|
"rc_attendance_behaviour": 4,
|
||||||
|
"rc_early_years": None,
|
||||||
|
"rc_safeguarding_met": True,
|
||||||
|
"overall_effectiveness": None,
|
||||||
|
}
|
||||||
|
labels = report_card_labels(ofsted)
|
||||||
|
assert labels == {
|
||||||
|
"rc_achievement": {"code": 2, "label": "Strong standard"},
|
||||||
|
"rc_inclusion": {"code": 3, "label": "Expected standard"},
|
||||||
|
"rc_attendance_behaviour": {"code": 4, "label": "Needs attention"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_code_is_skipped_not_crashed():
|
||||||
|
assert report_card_labels({"rc_achievement": 9}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_url():
|
||||||
|
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Run `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_ofsted_codes.py -q` → FAIL (module missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `backend/ofsted_codes.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Ofsted renewed-framework (Nov 2025) report-card code translation.
|
||||||
|
|
||||||
|
Scale labels are the live-sampled vocabulary from the Ofsted MI file
|
||||||
|
(see pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE) —
|
||||||
|
verified against real data, not the consultation draft.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REPORT_CARD_GRADE_NAMES = {
|
||||||
|
1: "Exceptional",
|
||||||
|
2: "Strong standard",
|
||||||
|
3: "Expected standard",
|
||||||
|
4: "Needs attention",
|
||||||
|
5: "Urgent improvement",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Graded evaluation areas only — safeguarding is a separate boolean
|
||||||
|
# judgement and must never appear in grade counts or label maps.
|
||||||
|
_RC_AREA_KEYS = (
|
||||||
|
"rc_inclusion",
|
||||||
|
"rc_curriculum_teaching",
|
||||||
|
"rc_achievement",
|
||||||
|
"rc_attendance_behaviour",
|
||||||
|
"rc_personal_development",
|
||||||
|
"rc_leadership_governance",
|
||||||
|
"rc_early_years",
|
||||||
|
"rc_sixth_form",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def report_card_labels(ofsted: dict) -> dict:
|
||||||
|
"""{area_key: {code, label}} for populated, known-valued rc_* areas."""
|
||||||
|
out = {}
|
||||||
|
for key in _RC_AREA_KEYS:
|
||||||
|
code = ofsted.get(key)
|
||||||
|
label = REPORT_CARD_GRADE_NAMES.get(code)
|
||||||
|
if code is not None and label is not None:
|
||||||
|
out[key] = {"code": code, "label": label}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ofsted_page_url(urn: int) -> str:
|
||||||
|
"""The school's page on ofsted.gov.uk (all its reports live there —
|
||||||
|
we never deep-link an individual report; spec §5)."""
|
||||||
|
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Re-run the test file → 4 passed. Run the full suite (same command, `backend/tests -q`) → all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5:** Commit: `feat(api): Ofsted report-card labels and provider-page URL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: data_loader — query columns + richer supplementary blocks (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` ~line 153; `get_supplementary_data` ~line 460)
|
||||||
|
- Test: `backend/tests/test_supplementary_enrichment.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `_MAIN_QUERY` additionally selects (KS2 block, after `p.maths_progress`): `p.reading_progress_lower_ci, p.reading_progress_upper_ci, p.writing_progress_lower_ci, p.writing_progress_upper_ci, p.writing_working_towards_pct, p.maths_progress_lower_ci, p.maths_progress_upper_ci`; (KS4 block, after the Progress 8 CI columns): `p.progress_8_banding, p.attainment_8_disadvantage_gap, p.progress_8_disadvantage_gap`. Note `_MAIN_QUERY_NO_SIXTH_FORM`/`_MAIN_QUERY_LEGACY_NAMES` are string-derived from `_MAIN_QUERY` (lines 259-270) and inherit automatically — verify the assertions there still hold.
|
||||||
|
- `get_supplementary_data(db, urn)["admissions"]` rows additionally carry: `total_offers`, `second_preference_offers`, `third_preference_offers`, `cross_la_applications`, `cross_la_offers` (add to `_admissions_row`).
|
||||||
|
- `get_supplementary_data(db, urn)["ofsted"]` additionally carries: `report_card` (the `report_card_labels(...)` dict, `{}` when no rc data), `ofsted_page_url`, and `grade_source`: `"graded"` when `overall_effectiveness` came from the graded column, `"ungraded_carried_forward"` when the fallback `ungraded_grade` supplied it, `None` when neither.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** — construct a fake Ofsted row object (simple `types.SimpleNamespace` with the model's attributes) and call the block-building logic via `get_supplementary_data` with a stubbed session (follow how existing tests stub the db; if none do, factor the ofsted-dict construction into a pure helper `_ofsted_block(o, urn)` and test that directly — preferred):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import types
|
||||||
|
from backend.data_loader import _ofsted_block
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kw):
|
||||||
|
base = dict(
|
||||||
|
framework="RC", inspection_date=None, inspection_type=None,
|
||||||
|
overall_effectiveness=None, quality_of_education=None,
|
||||||
|
behaviour_attitudes=None, personal_development=None,
|
||||||
|
leadership_management=None, early_years_provision=None,
|
||||||
|
sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None,
|
||||||
|
rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None,
|
||||||
|
rc_achievement=None, rc_attendance_behaviour=None,
|
||||||
|
rc_personal_development=None, rc_leadership_governance=None,
|
||||||
|
rc_early_years=None, rc_sixth_form=None, report_url=None,
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return types.SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_card_block_and_provider_url():
|
||||||
|
o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True)
|
||||||
|
block = _ofsted_block(o, urn=100140)
|
||||||
|
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
|
||||||
|
assert "rc_safeguarding_met" not in block["report_card"]
|
||||||
|
assert block["rc_safeguarding_met"] is True
|
||||||
|
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grade_source_graded_vs_carried_forward():
|
||||||
|
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
|
||||||
|
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
|
||||||
|
assert carried["grade_source"] == "ungraded_carried_forward"
|
||||||
|
assert carried["overall_effectiveness"] == 2
|
||||||
|
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_admissions_row_new_fields():
|
||||||
|
from backend.data_loader import _admissions_row_dict
|
||||||
|
a = types.SimpleNamespace(
|
||||||
|
year=202627, school_phase="Primary", places_offered=80,
|
||||||
|
total_applications=185, first_preference_applications=74,
|
||||||
|
first_preference_offers=74, first_preference_offer_pct=100.0,
|
||||||
|
oversubscription_ratio=0.925, oversubscribed=False,
|
||||||
|
total_offers=80, second_preference_offers=4, third_preference_offers=2,
|
||||||
|
cross_la_applications=12, cross_la_offers=3,
|
||||||
|
)
|
||||||
|
d = _admissions_row_dict(a)
|
||||||
|
for k in ("total_offers", "second_preference_offers", "third_preference_offers",
|
||||||
|
"cross_la_applications", "cross_la_offers"):
|
||||||
|
assert d[k] == getattr(a, k)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Run → FAIL (helpers don't exist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** Refactor the existing inline ofsted-dict construction in `get_supplementary_data` into a module-level `_ofsted_block(o, urn)` that produces the existing keys **unchanged** plus the three new ones (`report_card` via `report_card_labels(...)` from Task 3, `ofsted_page_url` via `ofsted_page_url(urn)`, `grade_source` per the interface rule — derived from which source supplied `overall_effectiveness`). Rename/extract the local `_admissions_row` into module-level `_admissions_row_dict(a)` and append the five new fields. Add the ten new columns to `_MAIN_QUERY` exactly as the interface lists them. `get_supplementary_data` calls both helpers; its external shape gains only additive keys.
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Full suite → all pass (existing `test_school_details.py` etc. must not break; if a fixture enumerates yearly-data columns, extend it with the new NaN columns as needed).
|
||||||
|
|
||||||
|
- [ ] **Step 5:** Commit: `feat(api): expose progress CIs, KS4 banding/gaps, admissions detail, report-card labels`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Computed benchmarks helper (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/data_loader.py` (new function)
|
||||||
|
- Test: `backend/tests/test_benchmarks.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces for Task 6: `compute_benchmarks(df) -> dict` — pure function over the main dataframe (latest year, state schools), shape:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"source": "state-school average (computed from our dataset)",
|
||||||
|
"year": 202425,
|
||||||
|
"primary": {
|
||||||
|
"disadvantaged_rwm_expected_pct": 46.1, # weighted by eligible_pupils
|
||||||
|
"eal_pct": 22.3, # median
|
||||||
|
"sen_support_pct": 14.0, # median
|
||||||
|
"disadvantaged_pct": 24.8, # median (FSM6 proxy)
|
||||||
|
"median_pupils": 281, # median school size
|
||||||
|
},
|
||||||
|
"secondary": { "median_pupils": 1024, "eal_pct": ..., "sen_support_pct": ..., "disadvantaged_pct": ... },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** — build a small synthetic df (6 primary rows with known eligible_pupils/rwm_expected_disadvantaged_pct so the weighted average is hand-checkable; a couple of secondary rows flagged by non-null `attainment_8_score`), assert: weighted disadvantaged average matches hand computation (not the unweighted mean), medians ignore NaN, secondary block lacks the disadvantaged-RWM key, latest-year filtering (rows from an older year must not affect results), and empty df → `{}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Run → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** in `data_loader.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compute_benchmarks(df: pd.DataFrame) -> dict:
|
||||||
|
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
|
||||||
|
These are NOT official DfE figures — consumers must label them
|
||||||
|
'state-school average (computed from our dataset)'."""
|
||||||
|
if df.empty or "year" not in df.columns:
|
||||||
|
return {}
|
||||||
|
latest_year = df["year"].max()
|
||||||
|
d = df[df["year"] == latest_year]
|
||||||
|
if d.empty:
|
||||||
|
return {}
|
||||||
|
is_secondary = d["attainment_8_score"].notna() if "attainment_8_score" in d.columns else pd.Series(False, index=d.index)
|
||||||
|
prim, sec = d[~is_secondary], d[is_secondary]
|
||||||
|
|
||||||
|
def _median(sub, col):
|
||||||
|
if col not in sub.columns:
|
||||||
|
return None
|
||||||
|
v = sub[col].median()
|
||||||
|
return round(float(v), 1) if pd.notna(v) else None
|
||||||
|
|
||||||
|
def _weighted_disadvantaged(sub):
|
||||||
|
if not {"rwm_expected_disadvantaged_pct", "eligible_pupils"} <= set(sub.columns):
|
||||||
|
return None
|
||||||
|
s = sub.dropna(subset=["rwm_expected_disadvantaged_pct", "eligible_pupils"])
|
||||||
|
if s.empty or s["eligible_pupils"].sum() == 0:
|
||||||
|
return None
|
||||||
|
w = (s["rwm_expected_disadvantaged_pct"] * s["eligible_pupils"]).sum() / s["eligible_pupils"].sum()
|
||||||
|
return round(float(w), 1)
|
||||||
|
|
||||||
|
def _block(sub, with_disadvantaged):
|
||||||
|
block = {
|
||||||
|
"eal_pct": _median(sub, "eal_pct"),
|
||||||
|
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||||
|
"disadvantaged_pct": _median(sub, "disadvantaged_pct"),
|
||||||
|
"median_pupils": int(sub["total_pupils"].median()) if "total_pupils" in sub.columns and pd.notna(sub["total_pupils"].median()) else None,
|
||||||
|
}
|
||||||
|
if with_disadvantaged:
|
||||||
|
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||||
|
return block
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "state-school average (computed from our dataset)",
|
||||||
|
"year": int(latest_year),
|
||||||
|
"primary": _block(prim, with_disadvantaged=True),
|
||||||
|
"secondary": _block(sec, with_disadvantaged=False),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt column presence to the real df — `sen_support_pct` reaches the df via `_MAIN_QUERY`; confirm and add it there if the KS2 block doesn't already select it, mirroring Task 4's additions.)
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Full suite → pass. **Step 5:** Commit: `feat(api): computed state-school benchmarks`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Enrich `/api/compare` + expose GPS/science national averages (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app.py` (`compare_schools` ~line 636; `get_national_averages` ~line 730)
|
||||||
|
- Test: `backend/tests/test_compare_enrichment.py`
|
||||||
|
|
||||||
|
**Interfaces (response additions, all additive):**
|
||||||
|
- `/api/compare` top level gains: `"national_averages"` (same payload the `/api/national-averages` endpoint returns — extract the endpoint body into a helper `_national_averages_payload(df)` and reuse; do not duplicate the logic) and `"benchmarks"` (Task 5's `compute_benchmarks(df)`).
|
||||||
|
- Each `comparison[urn]` gains: `"ofsted"`, `"census"`, `"admissions"`, `"admissions_history"`, `"deprivation"` from `get_supplementary_data` (one `SessionLocal()` for the whole request, closed in `finally`; on exception the five keys are `None`/`[]` — mirror the detail endpoint's defensive pattern at app.py:583-590).
|
||||||
|
- `get_national_averages`' KS2 metric list gains `"gps_expected_pct", "gps_high_pct", "science_expected_pct"` so the England ticks for GPS/science flow once the data exists.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** — monkeypatch `load_school_data` with a two-school primary df (reuse/extend the fixture style of `test_school_details.py`) and monkeypatch `get_supplementary_data` to a canned dict; assert on `TestClient(app).get("/api/compare?urns=...")`:
|
||||||
|
- response keeps the existing shape (`comparison[urn]["school_info"]["rwm_expected_pct"]` etc.),
|
||||||
|
- each school gains the five supplementary keys (canned values round-tripped),
|
||||||
|
- top-level `national_averages` and `benchmarks` present; `benchmarks["source"]` is the exact provenance string,
|
||||||
|
- a supplementary-layer exception (monkeypatched to raise) degrades to `ofsted: None` etc. with HTTP 200,
|
||||||
|
- `/api/national-averages` includes `gps_expected_pct` in the primary block when the df/national table provides it (monkeypatch the national-averages source the endpoint reads).
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement per the interfaces. **Step 4:** Full suite → pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5:** Commit: `feat(api): compare endpoint carries supplementary blocks, national averages and benchmarks`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: PR + verification
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Full suite one more time + `uv run --with pyyaml python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))"` sanity is NOT needed (no workflow changes) — instead run the dbt parse gate again (Task 1 file).
|
||||||
|
- [ ] **Step 2:** Push, open PR via the Gitea API (credential-helper basic auth). PR body: the new response shapes (one JSON sketch), the reused-not-duplicated national-averages helper, the provenance rule for benchmarks, deploy note (fields NULL until prod DAGs run post-promotion), and that no e2e change is needed (no user-facing behaviour changes — the compare UI still reads the old fields; the frontend PR carries the journey updates).
|
||||||
|
- [ ] **Step 3:** After merge + staging deploy: `curl -s https://stx.schoolcompare.co.uk/api/compare?urns=138690,100140 | python3 -m json.tool | head -80` — verify the new keys and that `benchmarks.primary.disadvantaged_rwm_expected_pct` is plausible (~45-47). Verify `/api/national-averages` now carries `gps_expected_pct`/`science_expected_pct` (values or honest nulls if DfE suppresses them at national level).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Frontend rebuild + e2e journeys (next PR — consumes everything this PR exposes).
|
||||||
|
- `schemas.py` METRIC_DEFINITIONS additions for the trends picker (frontend PR decides which of the new columns become picker metrics).
|
||||||
|
- CI-based progress banding logic (frontend computes Above/Average/Below from the CI columns; historical years only).
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Compare Screen Frontend Rebuild Implementation 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:** Rebuild `/compare` in the Next.js app to match the approved mockups — parent-first sections (At a glance / Ofsted / Academics / Getting a place / Who goes there / Explore trends), England-average anchoring with provenance-correct labels, mobile-first measure-first layout — consuming the enriched `/api/compare` payload from PR #34, with e2e journeys updated in the same PR (they are the promotion gate).
|
||||||
|
|
||||||
|
**Architecture:** `ComparisonView` becomes an assembly of section components fed by one enriched fetch. All comprehension rules from the two expert reviews live in a pure, jest-tested module (`lib/compareLogic.ts`) — components stay presentational. The mockups are committed at `docs/superpowers/specs/mockups/compare-desktop.html` and `compare-mobile.html`: **all user-facing copy (labels, tooltips, chips, footnote wording) is taken verbatim from them** — they carry two rounds of education-expert review; do not paraphrase.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js (app router, SSR page + client view), CSS modules, Chart.js (existing `ComparisonChart`), Jest (`npm test` in `nextjs-app/`), Playwright e2e (`e2e/`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Never push to `main`.** Branch: `feat/compare-frontend-rebuild`.
|
||||||
|
- **Copy is expert-reviewed:** take it verbatim from the committed mockups. Binding rules (spec §8): Ofsted scale labels come from the API's `report_card[..].label` (never hardcode area labels beyond the mockups'); official DfE numbers say "England average", computed ones say "state-school average (computed from our dataset)"; the 2021/22 chart gap note says "DfE didn't publish school-level figures for 2021/22"; never derive an overall grade from report-card areas; safeguarding never counts as a graded area; "Latest Ofsted inspection", "EHC plans", "at or above capacity", "Over 1 in 4", "first choice (officially 'first preference')".
|
||||||
|
- **Mobile-first:** the measure-first stacked layout (mobile mockup) is the base CSS; the desktop label-column grid is the `min-width` enhancement.
|
||||||
|
- **URL contract unchanged:** `?urns=` (and `metric=` now scoped to Explore trends) keep working; share flow, `useComparison` basket, phase tabs, and `compare_viewed`/`compare_metric_changed` analytics events are preserved.
|
||||||
|
- **Do not run a local server** (CLAUDE.md); verification = jest + `tsc` + the e2e suite against staging after merge. e2e must pass on **staging data** — remember staging has partial history: assert against the *latest* year, never oldest.
|
||||||
|
- Existing `/api/compare` consumers elsewhere in the app (SchoolDetail links, toasts) must not break — the response is additive, and this PR only rewrites the compare page's own components.
|
||||||
|
- **Post-v1 (do not build):** IDACI, attendance section, gender-split/absence tier-2 measures, finance (spec §4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Branch + design sources
|
||||||
|
|
||||||
|
- [ ] `git checkout main && git pull && git checkout -b feat/compare-frontend-rebuild`
|
||||||
|
- [ ] The mockups and this plan are already in the working tree (`docs/superpowers/specs/mockups/compare-{desktop,mobile}.html`) — commit them: `docs: compare mockups as frontend design source + rebuild plan`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Types for the enriched payload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/lib/types.ts` (extend `SchoolResult`, `ComparisonData`, `ComparisonResponse` — located around lines 293-314)
|
||||||
|
|
||||||
|
**Interfaces (produced for every later task):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ReportCardEntry { code: number; label: string; }
|
||||||
|
|
||||||
|
export interface OfstedBlock {
|
||||||
|
framework: string | null;
|
||||||
|
inspection_date: string | null;
|
||||||
|
inspection_type: string | null;
|
||||||
|
overall_effectiveness: number | null;
|
||||||
|
grade_source: 'graded' | 'ungraded_carried_forward' | null;
|
||||||
|
quality_of_education: number | null;
|
||||||
|
behaviour_attitudes: number | null;
|
||||||
|
personal_development: number | null;
|
||||||
|
leadership_management: number | null;
|
||||||
|
early_years_provision: number | null;
|
||||||
|
sixth_form_provision: number | null;
|
||||||
|
rc_safeguarding_met: boolean | null;
|
||||||
|
report_card: Record<string, ReportCardEntry>;
|
||||||
|
ofsted_page_url: string;
|
||||||
|
report_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensusBlock {
|
||||||
|
year: number | null; total_pupils: number | null;
|
||||||
|
female_pupils: number | null; male_pupils: number | null;
|
||||||
|
fsm_pct: number | null; eal_pct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdmissionsRow {
|
||||||
|
year: number; school_phase: string | null;
|
||||||
|
places_offered: number | null; total_applications: number | null;
|
||||||
|
first_preference_applications: number | null; first_preference_offers: number | null;
|
||||||
|
first_preference_offer_pct: number | null; oversubscription_ratio: number | null;
|
||||||
|
oversubscribed: boolean | null;
|
||||||
|
total_offers: number | null; second_preference_offers: number | null;
|
||||||
|
third_preference_offers: number | null;
|
||||||
|
cross_la_applications: number | null; cross_la_offers: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeprivationBlock {
|
||||||
|
lsoa_code: string | null; idaci_score: number | null; idaci_decile: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BenchmarkBlock {
|
||||||
|
eal_pct: number | null; sen_support_pct: number | null;
|
||||||
|
disadvantaged_pct: number | null; median_pupils: number | null;
|
||||||
|
disadvantaged_rwm_expected_pct?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Benchmarks {
|
||||||
|
source: string; year: number;
|
||||||
|
primary: BenchmarkBlock; secondary: BenchmarkBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NationalAverages {
|
||||||
|
year: number;
|
||||||
|
primary: Record<string, number>;
|
||||||
|
secondary: Record<string, number>;
|
||||||
|
by_year: Array<{ year: number; primary: Record<string, number>; secondary: Record<string, number> }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add the interfaces above; extend `ComparisonData` with optional `ofsted?: OfstedBlock | null; census?: CensusBlock | null; admissions?: AdmissionsRow | null; admissions_history?: AdmissionsRow[]; deprivation?: DeprivationBlock | null;` and `ComparisonResponse` with `national_averages?: NationalAverages; benchmarks?: Benchmarks;` (optional so the UI degrades on an old backend). Extend `SchoolResult` with the ten new yearly columns (`reading_progress_lower_ci` … `maths_progress_upper_ci`, `writing_working_towards_pct`, `progress_8_banding: string | null`, `attainment_8_disadvantage_gap`, `progress_8_disadvantage_gap`).
|
||||||
|
- [ ] **Step 2:** `cd nextjs-app && npx tsc --noEmit` → clean. Commit: `feat(compare): types for enriched comparison payload`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `lib/compareLogic.ts` — the comprehension rules, jest-tested
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `nextjs-app/lib/compareLogic.ts`
|
||||||
|
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts`
|
||||||
|
|
||||||
|
**Interfaces (produced):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type Verdict = 'above' | 'close' | 'below';
|
||||||
|
export function verdict(value: number, anchor: number, tolerance?: number): Verdict; // default tolerance 2pp
|
||||||
|
|
||||||
|
// Report-card summary per spec §4.2: count graded areas per label (best
|
||||||
|
// first), NAME any 'Needs attention'/'Urgent improvement' area, safeguarding
|
||||||
|
// separate, "No areas need attention" reassurance when applicable.
|
||||||
|
export interface ReportCardSummary {
|
||||||
|
counts: Array<{ label: string; count: number }>; // best grade first
|
||||||
|
problems: Array<{ areaLabel: string; label: string }>; // named, never counted-away
|
||||||
|
safeguarding: 'met' | 'not_met' | null;
|
||||||
|
allClear: boolean;
|
||||||
|
}
|
||||||
|
export function summariseReportCard(ofsted: OfstedBlock): ReportCardSummary;
|
||||||
|
|
||||||
|
// One display model for all three inspection regimes.
|
||||||
|
export type OfstedDisplay =
|
||||||
|
| { kind: 'none' }
|
||||||
|
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
|
||||||
|
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
|
||||||
|
| { kind: 'report_card'; summary: ReportCardSummary };
|
||||||
|
export function ofstedDisplay(ofsted: OfstedBlock | null | undefined): OfstedDisplay;
|
||||||
|
export const OFSTED_LEGACY_GRADES: Record<number, string>; // 1 Outstanding, 2 Good, 3 Requires improvement, 4 Inadequate
|
||||||
|
|
||||||
|
// Human-readable area label from an rc_ key: 'rc_attendance_behaviour' →
|
||||||
|
// 'Attendance & behaviour' (mapping table copied from the mockups' area rows).
|
||||||
|
export function rcAreaLabel(key: string): string;
|
||||||
|
|
||||||
|
// Admissions, one consistent chip metric (first-preference success).
|
||||||
|
export interface AdmissionsSummary {
|
||||||
|
firstPrefPct: number | null;
|
||||||
|
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null; // "97% of first choices offered" / "Over 1 in 4 first choices missed out" wording per mockups
|
||||||
|
interest: string | null; // "Named on 457 forms · 180 places"
|
||||||
|
}
|
||||||
|
export function summariseAdmissions(a: AdmissionsRow | null | undefined): AdmissionsSummary;
|
||||||
|
|
||||||
|
// CI-based progress band for historical years (null when no CI published).
|
||||||
|
export function progressBand(score: number | null, lower: number | null, upper: number | null):
|
||||||
|
'above' | 'average' | 'below' | null; // CI entirely >0 → above; entirely <0 → below; straddles → average
|
||||||
|
|
||||||
|
// Dot-strip geometry (used by the DotStrip component; pure for testing).
|
||||||
|
export interface StripPoint { pos: number; labelAbove: boolean; value: number; schoolIndex: number; }
|
||||||
|
export function stripPositions(values: Array<number | null>, min: number, max: number): StripPoint[];
|
||||||
|
// pos = (v-min)/(max-min)*100 clamped 0..100; labels within 4% of range of a
|
||||||
|
// lower neighbour flip above (the mockups' collision nudge).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** covering, at minimum:
|
||||||
|
- `summariseReportCard`: 4 Strong + 2 Expected + 1 Needs-attention + safeguarding met → counts `[Strong standard×4, Expected standard×2]`, `problems=[{areaLabel:'Attendance & behaviour', label:'Needs attention'}]`, `allClear=false`; safeguarding NEVER in counts; all-Expected+met → `allClear=true`; labels come from the input's `.label` (assert the function never invents "Attention needed").
|
||||||
|
- `ofstedDisplay`: report_card present → `kind:'report_card'` even if a legacy grade also exists; `grade_source:'ungraded_carried_forward'` → `carriedForward:true`; null → `'none'`.
|
||||||
|
- `summariseAdmissions`: 73% → warn chip text `Over 1 in 4 first choices missed out`; 97% → good chip `97% of first choices offered`; 100% → `All first choices offered`; interest string `Named on 342 forms · 120 places`; nulls → null chip.
|
||||||
|
- `progressBand`: (1.2, 0.4, 2.0)→above; (-1.2, -2.0, -0.4)→below; (0.3, -0.5, 1.1)→average; missing CI → null.
|
||||||
|
- `stripPositions`: 100–120 domain maps 106→30; values 91 and 92 on 0–100 → second label flips above; nulls skipped.
|
||||||
|
- `verdict`: 87 vs 62 → above; 61 vs 62 → close (within 2pp); 40 vs 62 → below.
|
||||||
|
- [ ] **Step 2:** `cd nextjs-app && npm test -- compareLogic` → FAIL. **Step 3:** implement. **Step 4:** pass + `tsc` clean. **Step 5:** Commit: `feat(compare): comprehension logic (report cards, admissions, verdicts, strips)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `DotStrip` component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `nextjs-app/components/DotStrip.tsx`, `nextjs-app/components/DotStrip.module.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface DotStripProps {
|
||||||
|
label: string;
|
||||||
|
values: Array<number | null>; // one per school, school order = chart colour order
|
||||||
|
anchor?: { value: number; label: string } | null; // e.g. {62, "England 62%"} — omit when benchmark absent
|
||||||
|
min?: number; max?: number; // default 0..100
|
||||||
|
unit?: string; // default '%'
|
||||||
|
tip?: string; // title tooltip on the label
|
||||||
|
note?: string; // e.g. "(teacher-assessed)" suffix handled by caller in label
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Render per the mockups' `.strip-row` anatomy: label row, 4px track, England tick + tick label, 16px dots coloured by `CHART_COLORS[index]` with white ring, value labels below (flipped above on collision via `stripPositions`). `role="img"` + `aria-label` enumerating anchor and each school's value (copy the aria pattern from the mockups). CSS module mirrors the mockup styles using the app's CSS variables (`--border-light`, `--text-muted`, etc.).
|
||||||
|
- [ ] Jest: render with `@testing-library/react` (already configured — see `__tests__/components/SecondarySchoolRow.test.tsx` for the harness pattern): asserts aria-label content, tick present when anchor given, absent otherwise.
|
||||||
|
- [ ] Commit: `feat(compare): DotStrip with England-average anchor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Section components — At a glance, Ofsted, Getting a place, Who goes there
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `nextjs-app/components/compare/CompareAtAGlance.tsx` (+ `.module.css`)
|
||||||
|
- Create: `nextjs-app/components/compare/CompareOfsted.tsx`
|
||||||
|
- Create: `nextjs-app/components/compare/CompareAdmissions.tsx`
|
||||||
|
- Create: `nextjs-app/components/compare/CompareCommunity.tsx`
|
||||||
|
- Create: `nextjs-app/components/compare/compareSections.module.css` (shared measure-first grid)
|
||||||
|
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
|
||||||
|
|
||||||
|
**Shared layout contract (all four):** props `{ schools: School[]; data: Record<string, ComparisonData>; benchmarks?: Benchmarks; nationalAverages?: NationalAverages }`. Base CSS is the mobile mockup's measure-first stack (`.measure` card → `.srow` per school with colour dot + short name + value + chip + note); at `min-width: 761px` it becomes the desktop mockup's grid (200px row-label column + one column per school). Section headers use the existing `.section-title` idiom; every section carries its mockup "how" line verbatim.
|
||||||
|
|
||||||
|
**Content per section = the mockups, row for row.** Structure/tone rules already encoded in Task 2's helpers:
|
||||||
|
- *At a glance*: Latest Ofsted inspection row (badge via `ofstedDisplay`; report-card case renders `ReportCardSummary` chips — counts best-first + named problem chips + safeguarding line); expected-standard row (big % + `verdict` chip vs `national_averages.primary.rwm_expected_pct`, small "England average N%"); Getting a place row (chip from `summariseAdmissions`, note = `interest`); Size row (pupils + "at or above capacity"/"N% full" from census/capacity, vs `benchmarks.*.median_pupils` for "larger/smaller than average" phrasing).
|
||||||
|
- *Ofsted*: the section's `how` paragraph (regime explanation + non-comparability + "Expected standard" disambiguation) verbatim from the desktop mockup; Result row; Inspected row (date + "4+ years ago" chip when >4y, computed from `inspection_date`); Judgement detail row — **one chip-list grammar for both regimes** (legacy subgrades via `OFSTED_LEGACY_GRADES`; report card via `report_card` labels; "We don't hold area-by-area detail for this inspection" when neither); Ofsted page row linking `ofsted_page_url` ("<Name>'s Ofsted page →").
|
||||||
|
- *Getting a place*: `how` paragraph (first preference/equal preference/offer-day caveats) verbatim; Interest row; first-choice success row with mini bar; "What this means" row (distance note: "check the school's admission criteria (for most non-faith primaries, distance decides)" only when oversubscribed).
|
||||||
|
- *Who goes there*: pupils-on-roll (census + capacity), girls/boys, FSM (chip vs `benchmarks` with "state-school average" wording), EAL, SEN (tooltip incl. "EHC plans" + specialist-provision note), faith, ages · nursery, run by (trust name or "<LA> council").
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Failing jest test for `CompareOfsted` (the riskiest): given one graded school, one carried-forward, one report-card school → asserts the three Result cells ("Outstanding" badge; badge + carried-forward marker; "Report card" + no invented overall grade), the chip-list judgement rows, and the comparability note appearing only for the mixed case.
|
||||||
|
- [ ] **Step 2-4:** Implement all four sections; test passes; `tsc` clean; `npm test` full suite green.
|
||||||
|
- [ ] **Step 5:** Commit: `feat(compare): at-a-glance, Ofsted, admissions and community sections`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `CompareAcademics` — strips + More measures
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `nextjs-app/components/compare/CompareAcademics.tsx`
|
||||||
|
- Test: extend `nextjs-app/__tests__/lib/compareLogic.test.ts` with the metric-extraction helper below
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Add to `compareLogic.ts`: `latestValues(data, urns, metricKey) => Array<number|null>` (latest non-null yearly value per school) — tested.
|
||||||
|
|
||||||
|
- [ ] Tier 1 strips (always visible), each a `DotStrip` with the England anchor from `national_averages.primary`: RWM expected, Reading, Writing, Maths, "Working at a higher standard than expected" (tooltip: composition sentence from the mockups). Section `how` line: "tests and teacher assessments … writing is assessed by teachers, not tested" verbatim.
|
||||||
|
- [ ] Tier 2 `<details>` "More measures — grammar, punctuation & spelling, science, average scaled scores": GPS + Science (teacher-assessed, tooltip verbatim) with anchors from `national_averages` **when present, no tick + honest note when null**; scaled scores (reading/maths/GPS) on `min=100 max=120` with the mockups' window caption.
|
||||||
|
- [ ] Equity row: disadvantaged pupils' RWM per school + chip vs `benchmarks.primary.disadvantaged_rwm_expected_pct` with the "state-school average" wording and small-cohort tooltip verbatim.
|
||||||
|
- [ ] Secondary phase variant (when active phase is secondary): tier-1 rows are Attainment 8 (anchor `national_averages.secondary.attainment_8_score`), Progress 8 banding (chip showing `progress_8_banding` verbatim — DfE's own label), grade 5+ English & maths %; tier-2: EBacc entry/APS. Measure-first rows (no strips needed for banding).
|
||||||
|
- [ ] `npm test` + `tsc`; commit: `feat(compare): academics strips with England anchors and More measures`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Trends explorer — England line, gap-honest axis, series bug
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/components/ComparisonChart.tsx`
|
||||||
|
- Create: `nextjs-app/components/compare/TrendsExplorer.tsx`
|
||||||
|
- Test: `nextjs-app/__tests__/components/ComparisonChart.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1 (bug first): root-cause the missing third series** seen on production (3 schools in table, 2 lines on chart). Write a failing jest test: 3 schools whose `yearly_data` year values are floats (`202425.0`) vs the labels array — the suspect is the year-matching in `ComparisonChart.tsx:69` (`years.map(...)` built from school 1 only + strict equality against other schools' years). Fix so every school's series renders and years are the union of all schools' years, sorted.
|
||||||
|
- [ ] **Step 2:** Add optional `nationalByYear?: Record<number, number|null>` prop → dashed grey "England average" dataset (colour `--text-muted`, `borderDash:[5,4]`, no fill, `spanGaps:false`).
|
||||||
|
- [ ] **Step 3:** Gap honesty: x-axis category labels include 2019/20 and 2020/21 as empty slots (band label "tests cancelled 2019/20–2020/21" via a Chart.js annotation-free approach: two category ticks with all-null data and a subtitle note under the chart, copy verbatim: the chart footnote "DfE didn't publish school-level figures for 2021/22" appears when the metric is a KS2 measure and 2021/22 school values are null while the England value exists). `spanGaps:false` on school datasets so dataset gaps break lines.
|
||||||
|
- [ ] **Step 4:** `TrendsExplorer` wraps the grouped metric picker (existing optgroup structure and `metrics` from `/api/metrics`, existing analytics event) + the chart + the existing year-by-year table, inside a collapsed-by-default `<details>` ("Explore trends"). Progress metrics annotate cells with `progressBand` chips for years where CIs exist.
|
||||||
|
- [ ] Tests pass; commit: `feat(compare): trends explorer with England line; fix missing series`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Assemble the new `ComparisonView`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `nextjs-app/components/ComparisonView.tsx` (+ its `.module.css`)
|
||||||
|
- Modify: `nextjs-app/app/compare/page.tsx` metadata description (mention Ofsted/admissions, not just KS2)
|
||||||
|
|
||||||
|
- [ ] Preserve intact: `useComparison` basket seeding/URL sync (lines 76-122 of the current file), share handler, phase tabs + auto-detection, `compare_viewed` analytics, empty states, `SchoolSearchModal`, max-4-visible column scroll. Replace the metric-picker/chart/table body with the section stack: sticky school chip bar (mockup `.school-bar`) → `CompareAtAGlance` → `CompareOfsted` → `CompareAcademics` → `CompareAdmissions` → `CompareCommunity` → `TrendsExplorer`. The page-level `metric` URL param now initialises `TrendsExplorer`'s picker only.
|
||||||
|
- [ ] Top-of-page subtitle + sources footnote verbatim from the mockups (minus the "Mockup" banner), including the suppression rule sentence and provenance sentence.
|
||||||
|
- [ ] `npm test` full suite + `tsc` clean. Commit: `feat(compare): parent-first compare screen assembly`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: e2e journeys (the promotion gate)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `e2e/tests/journeys.spec.ts` (the two compare tests, lines ~141-215; extend, don't delete coverage)
|
||||||
|
|
||||||
|
- [ ] Update 'comparing two schools shows both side by side': after loading `/compare?urns=…` assert the new section headings (`At a glance`, `Ofsted inspection`, `How children do academically`, `Getting a place`, `Who goes there`, `Explore trends`), both school names in the sticky bar, at least one England-average tick label (`text=/England \d+%/`), and one provenance string `state-school average` somewhere (benchmarks row). Data-invariant style — no exact numbers (staging data shifts; use latest-year values only).
|
||||||
|
- [ ] Update the mobile test: 390px viewport, assert measure-first stacking (a `.measure`-card contains all selected school names within one card) and that the trends chart container scrolls (`overflow-x`).
|
||||||
|
- [ ] Add a report-card presence-agnostic assertion: the Ofsted section renders either a grade badge or "Report card" without an overall grade — i.e. never both an overall-grade badge AND report-card chips for the same school.
|
||||||
|
- [ ] Run against staging from the host if reachable (`cd e2e && BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "compar"`) — staging still runs the OLD UI until this PR merges, so expect failures locally; the authoritative run is the Stage pipeline post-merge. Still commit only after jest+tsc are green.
|
||||||
|
- [ ] Commit: `test(e2e): compare journeys for the parent-first redesign`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: PR + post-merge verification
|
||||||
|
|
||||||
|
- [ ] Full gates: `cd nextjs-app && npm test && npx tsc --noEmit`.
|
||||||
|
- [ ] Push; open PR via Gitea API (credential-helper basic auth). PR body: before/after summary, link to mockups + spec §4/§8, the copy-verbatim rule, the fixed third-series bug, deploy note (needs PR #34's API on the same environment — merge order: #34 first), and that the e2e suite is the staging gate.
|
||||||
|
- [ ] Post-merge: watch the Stage pipeline — its e2e run against staging is the real verification. Then the human tests staging and promotes (two-stage model). Update memory: compare redesign shipped to staging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- IDACI / attendance / gender-absence / finance (post-v1, spec §4).
|
||||||
|
- Backend changes of any kind (PR #34 must merge first).
|
||||||
|
- Chart palette overhaul beyond the England-line addition (`CHART_COLORS` swap to the validated trio is a candidate follow-up, flagged not included — it affects every chart in the app).
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Staged Production Promotion (Manual Gate) Implementation 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:** Merging a PR deploys to staging only; production deployment requires a second, explicit human approval after manual testing on staging.
|
||||||
|
|
||||||
|
**Architecture:** Split the existing single `deploy.yml` pipeline in two. The push-to-main workflow keeps build → staging deploy → e2e gate and **stops there**. A new `promote.yml` runs only on `workflow_dispatch` (the "Run workflow" button in Gitea's Actions UI, supported on this server — Gitea 1.26.4): it verifies the chosen commit passed the staging e2e gate, retags its `:sha-*` images to `:prod` (keeping `:prod-previous` for rollback), and triggers the Portainer prod webhook. Promotion granularity is a main-branch commit: staging always runs the latest main, so you approve a *state of main*, not an individual PR.
|
||||||
|
|
||||||
|
**Tech Stack:** Gitea Actions (1.26.4), Docker buildx imagetools, Portainer webhooks, Gitea commit-status API.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Never push to `main` directly** — this change itself goes through a PR (`chore/staged-prod-promotion` branch).
|
||||||
|
- Existing image tagging scheme is unchanged: `type=sha` (e.g. `sha-6f925ab`) + `:staging`; promotion still retags `:sha-*` → `:prod` with `:prod-previous` kept as the rollback pointer.
|
||||||
|
- The e2e journeys remain a **hard gate before human testing** (a red staging never reaches the promote button) and the promote workflow must refuse to promote a commit whose staging e2e did not succeed.
|
||||||
|
- Secrets already exist and are reused: `REGISTRY_TOKEN` (also a Gitea API token), `PORTAINER_STAGING_WEBHOOK`, `PORTAINER_PROD_WEBHOOK`, `STAGING_BASE_URL`, `PROD_BASE_URL`.
|
||||||
|
- Staging quirk (memory): external `/api` is broken at the staging proxy — manual API testing happens from the host, not through stx.schoolcompare.co.uk; note it in the runbook, don't try to fix it in this plan.
|
||||||
|
|
||||||
|
## Considered approaches (context for the reviewer)
|
||||||
|
|
||||||
|
1. **Manual `workflow_dispatch` promote workflow (chosen).** Native on Gitea 1.26; the second approval is clicking "Run workflow" (or one API call) after testing staging. Least machinery, auditable via the Actions run history.
|
||||||
|
2. *Tag-driven promotion* (`push: tags: promote-*`): works on any Gitea version; approval = pushing a tag. Slightly more scriptable, less discoverable; kept as documented fallback only.
|
||||||
|
3. *GitOps `production` branch + promotion PR:* approval literally reuses the PR-review UI, but adds a second long-lived branch to keep in sync — too much ceremony for a solo project. Rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Branch
|
||||||
|
|
||||||
|
- [ ] `git checkout main && git pull && git checkout -b chore/staged-prod-promotion`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Stop the push-to-main workflow after the e2e gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: images tagged `:sha-<short>` + `:staging` (unchanged), a green `E2E Journeys against Staging` commit status that Task 2's promote workflow checks by name. **Do not rename the `e2e-staging` job's `name:` without updating Task 2's status check.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the auto-promotion**
|
||||||
|
|
||||||
|
In `.gitea/workflows/deploy.yml`:
|
||||||
|
1. Change line 1 to: `name: Stage (build -> staging -> E2E gate)`
|
||||||
|
2. Delete the entire `promote-prod` job (lines 196–240 in the current file: from ` promote-prod:` to the end of the file).
|
||||||
|
3. Leave `build-*`, `deploy-staging`, and `e2e-staging` untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Sanity-check the YAML**
|
||||||
|
|
||||||
|
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml')); print('yaml ok')"`
|
||||||
|
Expected: `yaml ok`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/deploy.yml
|
||||||
|
git commit -m "ci: stop deploy pipeline at staging; production promotion becomes manual"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Manual promote workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/promote.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `:sha-<short>` images built by deploy.yml; the `E2E Journeys against Staging` commit status.
|
||||||
|
- Produces: `:prod` and `:prod-previous` tags; prod stack update.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the workflow**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Promote to Production (manual)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sha:
|
||||||
|
description: >-
|
||||||
|
Commit SHA on main to promote (full or >=7 chars).
|
||||||
|
Leave empty to promote the latest main commit.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: privaterepo.sitaru.org
|
||||||
|
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||||
|
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||||
|
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote-prod:
|
||||||
|
name: Promote approved commit to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Resolve target SHA
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
SHA_INPUT="${{ gitea.event.inputs.sha }}"
|
||||||
|
if [ -z "$SHA_INPUT" ]; then
|
||||||
|
SHA_INPUT="${{ gitea.sha }}"
|
||||||
|
fi
|
||||||
|
# Normalise to the full sha via the API so short inputs work
|
||||||
|
FULL_SHA=$(curl -fsS \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/git/commits/${SHA_INPUT}" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")
|
||||||
|
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
|
||||||
|
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
||||||
|
|
||||||
|
- name: Verify the staging E2E gate passed for this commit
|
||||||
|
run: |
|
||||||
|
STATUS_JSON=$(curl -fsS \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
||||||
|
echo "$STATUS_JSON" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
ok = [s for s in d.get('statuses', [])
|
||||||
|
if 'E2E Journeys against Staging' in s.get('context', '')
|
||||||
|
and s.get('status') == 'success']
|
||||||
|
if not ok:
|
||||||
|
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
|
||||||
|
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
|
||||||
|
sys.exit(1)
|
||||||
|
print('E2E gate verified green for this commit.')
|
||||||
|
"
|
||||||
|
|
||||||
|
- 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 approved images as prod (keeping rollback pointer)
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${{ steps.resolve.outputs.short }}"
|
||||||
|
for IMAGE in \
|
||||||
|
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
|
||||||
|
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
|
||||||
|
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
|
||||||
|
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 }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes for the engineer:
|
||||||
|
- Gitea Actions uses the GitHub-compatible `$GITHUB_OUTPUT` file for step outputs; if the runner image doesn't populate it, fall back to `$GITEA_OUTPUT` (check the runner's docs/output at first run).
|
||||||
|
- The retag step is copied verbatim from the old `promote-prod` job except the SHA comes from the resolved input instead of `gitea.sha` — behaviour for the default (empty input on latest main) is identical to before.
|
||||||
|
- If `docker buildx imagetools create` fails with "not found" for `${IMAGE}:${SHORT_SHA}`, the chosen commit predates the registry's retention or never built — the error message is the desired behaviour (refuse loudly).
|
||||||
|
|
||||||
|
- [ ] **Step 2: YAML sanity check**
|
||||||
|
|
||||||
|
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/promote.yml')); print('yaml ok')"`
|
||||||
|
Expected: `yaml ok`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/promote.yml
|
||||||
|
git commit -m "ci: manual production promotion workflow with e2e-gate verification"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Documentation — deploy model + runbook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/DEPLOY.md`
|
||||||
|
- Modify: `claude.md` (the SDLC section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the flow description in `docs/DEPLOY.md`**
|
||||||
|
|
||||||
|
Replace the staging→prod description with the new model (adapt to the file's existing structure; the substance to convey):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Deploy model
|
||||||
|
|
||||||
|
1. **PR → main (first approval).** Branch-protected merge; PR checks
|
||||||
|
(typecheck, tests, builds, AI review) must pass.
|
||||||
|
2. **Merge → staging (automatic).** Images are built once and tagged
|
||||||
|
`sha-<short>` + `staging`; the staging stack updates; Playwright
|
||||||
|
journeys in `e2e/` run against staging. A red e2e run means staging
|
||||||
|
is not fit for testing — fix forward before considering promotion.
|
||||||
|
3. **Manual testing on staging.** stx.schoolcompare.co.uk. Note:
|
||||||
|
external `/api` is broken at the staging proxy — exercise API
|
||||||
|
endpoints from the host.
|
||||||
|
4. **Promote → production (second approval).** Actions → "Promote to
|
||||||
|
Production (manual)" → Run workflow. Leave the SHA empty to promote
|
||||||
|
the latest main, or paste a specific commit SHA. The workflow
|
||||||
|
refuses commits whose staging e2e gate is not green, retags the
|
||||||
|
images `:prod` (keeping `:prod-previous`), and updates the prod
|
||||||
|
stack.
|
||||||
|
|
||||||
|
### Promotion granularity
|
||||||
|
|
||||||
|
Staging always runs the latest `main`. Promoting approves a *state of
|
||||||
|
main*, not a single PR — if two PRs merged since the last promotion,
|
||||||
|
they ship together. Test staging accordingly.
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Re-run "Promote to Production (manual)" with the SHA of the last good
|
||||||
|
commit (or retag manually: `docker buildx imagetools create -t
|
||||||
|
<image>:prod <image>:prod-previous` for each of the three images, then
|
||||||
|
POST the prod Portainer webhook).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the SDLC bullet in `claude.md`**
|
||||||
|
|
||||||
|
Replace the sentence "Merging to `main` deploys automatically: … retagged `:prod` and rolled out to production." with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Merging to `main` deploys automatically **to staging only**: images
|
||||||
|
are built once, deployed to the staging Portainer stack, and verified
|
||||||
|
by the Playwright journeys in `e2e/`. Production is a second, manual
|
||||||
|
approval: the "Promote to Production (manual)" workflow in Gitea
|
||||||
|
Actions, run after testing the feature on staging. It refuses commits
|
||||||
|
whose staging e2e gate isn't green.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/DEPLOY.md claude.md
|
||||||
|
git commit -m "docs: two-stage deploy model (staging auto, production manual)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: PR + live validation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push and open the PR** (Gitea API with credential-helper basic auth, as usual). PR body: the new model in three lines, the rollback recipe, and a warning that between merging this PR and its first promotion run, production receives no deployments (expected).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate after merge (human-in-the-loop):**
|
||||||
|
1. Merge this PR → confirm the `Stage (build -> staging -> E2E gate)` run goes green and **no** production deployment happens (prod image digest unchanged: `docker buildx imagetools inspect <image>:prod` before/after, or check the Portainer prod stack's last-update time).
|
||||||
|
2. Test something trivial on staging.
|
||||||
|
3. Run "Promote to Production (manual)" with the SHA empty → confirm e2e verification passes, retag happens, prod becomes healthy.
|
||||||
|
4. Negative test: run the promote workflow with a garbage SHA (e.g. `deadbeef1`) → confirm it fails at resolve/verify without touching `:prod`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the ledger/memory** with the new deploy model so future sessions stop assuming auto-promotion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope / future options
|
||||||
|
|
||||||
|
- Notifications when staging is ready for testing (Gitea can email on workflow completion; a webhook to ntfy/Matrix could be added later).
|
||||||
|
- Restricting who can run the promote workflow: Gitea 1.26 runs `workflow_dispatch` with the permissions of the dispatching user; for a solo repo this is already effectively restricted.
|
||||||
|
- The tag-driven fallback (`on: push: tags: promote-*`) if `workflow_dispatch` ever proves unreliable on the runner.
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
# Compare Screen Must-Fix (Final Expert Review) Implementation 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:** Fix the five promotion-blocking findings from the expert's final staging review: (1) blank all-secondary compare view, (2) report cards dated with pre-Nov-2025 legacy inspection dates, (3) FSM chip benchmarked against the wrong measure, (4) KS4 "national averages" that are dataset means presented as official DfE figures, (5) factually wrong "DfE didn't publish 2021/22" footnote.
|
||||||
|
|
||||||
|
**Architecture:** One branch/PR touching all three layers. Pipeline: a new tap field carries the report-card inspection's own date; a new EES stream ingests official KS4 national headlines; a new census-benchmarks mart replaces junk KS2-derived context medians. Backend: serialize the new fields, stop mislabelling computed KS4 means as official. Frontend: fix the phase-detection effect that leaves all-secondary comparisons stuck on an empty "primary" tab, date report cards correctly, drop the FSM→disadvantaged fallback, fix the footnote copy.
|
||||||
|
|
||||||
|
**Tech Stack:** Meltano/Singer taps (Python), dbt-postgres, FastAPI/SQLAlchemy/pandas, Next.js app router + Jest, Playwright e2e.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Never push to `main`; work on branch `fix/compare-final-review-mustfix`, open a PR. Never trigger the "Promote to Production (manual)" workflow — promotion is exclusively the human's call.
|
||||||
|
- User-facing behaviour changes must extend the `e2e/` journeys in the same PR (they gate staging fitness and promotability).
|
||||||
|
- All user-facing copy on the compare screen comes verbatim from `docs/superpowers/specs/mockups/compare-desktop.html` / `compare-mobile.html` — except where this plan explicitly changes copy to fix a factual error (Task 6); the spec/mockup gets the same wording in the same commit.
|
||||||
|
- Benchmark provenance house style: official figures = "England average"; computed figures = "state-school average (computed from our dataset)".
|
||||||
|
- A report card must NEVER be displayed with a pre-November-2025 date. Report cards exist only from November 2025.
|
||||||
|
- Backend tests: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q` (repo root; there is no local pytest).
|
||||||
|
- dbt: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` (never bare `dbt` — the Fusion binary shadows dbt-postgres).
|
||||||
|
- Frontend: `cd nextjs-app && npx tsc --noEmit && npm test` (run tsc un-piped so exit codes are not masked).
|
||||||
|
- Do NOT start a local server to test the application (CLAUDE.md).
|
||||||
|
- Commits end with the Claude Code `Co-Authored-By` + `Claude-Session` trailers used on this branch's history.
|
||||||
|
|
||||||
|
## Root-Cause Evidence (verified 2026-07-16, do not re-derive)
|
||||||
|
|
||||||
|
- **Finding 1:** `nextjs-app/components/ComparisonView.tsx:164-176` — the auto-phase effect returns early when `selectedSchools.length === 0` (basket hydrates a beat after mount) and its dep array is only `[comparisonData]`, so it never re-fires; `comparePhase` stays `'primary'`, `activeSchools` is empty, the page renders "No primary schools in your comparison" (a11y snapshot confirmed). No console errors — not a crash.
|
||||||
|
- **Finding 2:** In the Ofsted MI CSV (`Management_information_-_state-funded_schools_-_latest_inspections_as_at_31_May_2026.csv`) the report-card grade columns (cols 38–55, "Safeguarding standards", "Inclusion", …) belong to the **latest full inspection** block whose date is col 30 "Inspection start date" (Barclay 138690: `03/02/2026`). The tap's `inspection_date` COLUMN_PRIORITY matches col 60 "Inspection start date of latest OEIF graded inspection" first (the *legacy* date; NULL for Barclay, so stg coalesces to the 2021 *ungraded* date). The rc data is **real Ofsted data, not fabricated** — it is mis-dated. Also `discover_csv_url()` returns `matches[0]` = the oldest (2017) link on the GOV.UK page; staging works only because `mi_url` is set in the environment. Staging raw is stale for at least Watford Grammar 136276 (staging shows rc grades; the current MI file has all rc columns NULL for it) — a fresh extract fixes that via upsert on `(urn, inspection_date)`.
|
||||||
|
- **Finding 3:** `nextjs-app/components/compare/CompareCommunity.tsx:36` — `bench?.fsm_pct ?? bench?.disadvantaged_pct` falls back across definitions. `benchmarks.primary.fsm_pct` is null because `compute_benchmarks` (backend/data_loader.py:528) medians the *performance* df, which has no `fsm_pct` (school FSM comes from `census.fsm_pct` = `fact_pupil_characteristics`). `disadvantaged_pct` / `eal_pct` are KS2-only columns, so the "secondary" medians (50.0 / 10.0) are computed over the few all-through schools' KS2 rows — junk.
|
||||||
|
- **Finding 4:** `fact_ks4_national_averages.sql` computes unweighted school means (A8 38.94 vs official 46.0; national P8 −0.27, impossible). Official series exists on EES: data-set `1b649e16-01e8-435b-a814-56be2faf9054` ("National characteristics summary data", KS4 performance publication), CSV endpoint same pattern as the KS2 national stream, national level, 2018/19→2024/25, `establishment_type_group = 'All state-funded'`, `breakdown_topic = 'Total'`, `breakdown = 'Total'`. Verified values: 2024/25 A8 46.0, P8 `z` (not published — no KS2 baseline for that cohort), EM 9-5 45.4%, EBacc entry 40.5%. It has **no** `gcse_91_percent` column.
|
||||||
|
- **Finding 5:** `ComparisonChart.tsx:245-246` claims "DfE didn't publish school-level figures for 2021/22". False — DfE published school-level KS2 for 2021/22 in Dec 2022; spec §8.1 itself lists loading it as a pipeline task. The honest claim is that the figures aren't in our dataset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: All-secondary comparison renders (phase-detection fix)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/components/ComparisonView.tsx:176`
|
||||||
|
- Create: `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx`
|
||||||
|
- Modify: `e2e/tests/journeys.spec.ts` (add helper + journey after the existing `twoPrimaryUrns` helper / primary compare journey)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `ComparisonView` props (`initialData`, `initialUrns`, `metrics`, `selectedMetric`), `ComparisonProvider`.
|
||||||
|
- Produces: no API changes; the auto-phase effect re-runs when the basket hydrates.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing Jest test**
|
||||||
|
|
||||||
|
Create `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx` (mirrors the mock setup of `ComparisonView.refresh.test.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/**
|
||||||
|
* Regression: an all-secondary comparison must render the secondary sections.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||||
|
* effect must re-run once selectedSchools arrives — with deps of only
|
||||||
|
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||||
|
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||||
|
* comparison") even though all schools were secondary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function secondarySchool(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy converter',
|
||||||
|
attainment_8_score: 55,
|
||||||
|
phase: 'Secondary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: secondarySchool(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'300': data(300, 'Gamma High'),
|
||||||
|
'400': data(400, 'Delta Academy'),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: { attainment_8_score: 46 },
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[300, 400]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="attainment_8_score"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx jest __tests__/components/ComparisonView.phase.test.tsx`
|
||||||
|
Expected: FAIL — "No primary schools in your comparison" is rendered / "At a glance" never appears.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the effect dependencies**
|
||||||
|
|
||||||
|
In `nextjs-app/components/ComparisonView.tsx`, the auto-phase effect currently ends:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||||
|
// first run sees an empty basket and bails, so it must re-fire when the
|
||||||
|
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||||
|
// are intentionally omitted (derived or would cause loops).
|
||||||
|
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
(`phaseLockedByUser` still suppresses re-detection after a manual tab click; re-running with unchanged inputs sets the same state, which React treats as a no-op.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the new test and the existing suite**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||||
|
Expected: PASS, including `ComparisonView.refresh.test.tsx` (the refresh regression must stay green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the e2e secondary journey**
|
||||||
|
|
||||||
|
In `e2e/tests/journeys.spec.ts`, add below `twoPrimaryUrns`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||||
|
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `/api/schools` list rows lack `attainment_8_score`, filter on `s.phase === 'Secondary'` only — check the response first.) Then add a journey test next to the primary compare journey:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||||
|
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The parent-first sections must render — this page was completely blank
|
||||||
|
// for all-secondary baskets (expert review must-fix #1).
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ofsted inspection' })).toBeVisible();
|
||||||
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add nextjs-app/components/ComparisonView.tsx nextjs-app/__tests__/components/ComparisonView.phase.test.tsx e2e/tests/journeys.spec.ts
|
||||||
|
git commit -m "fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Report-card inspection date through the pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY, schema, `discover_csv_url`)
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql`
|
||||||
|
- Modify: `pipeline/transform/models/intermediate/int_ofsted_latest.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ofsted_inspection.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (add column doc if other fact_ofsted columns are documented there)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: MI CSV column `Inspection start date` (the latest **full** inspection = the report-card inspection in the renewed framework; NULL when a school's only inspections are legacy OEIF/ungraded — verified for Watford Grammar).
|
||||||
|
- Produces: `marts.fact_ofsted_inspection.rc_inspection_date` (DATE, null unless the row carries report-card grades). Task 3 depends on this exact column name.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the tap field**
|
||||||
|
|
||||||
|
In `tap.py` COLUMN_PRIORITY, after the `rc_sixth_form` entry, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Date of the latest FULL inspection — in the renewed framework this is
|
||||||
|
# the report-card inspection's own start date (col "Inspection start
|
||||||
|
# date"), distinct from the legacy OEIF graded/ungraded dates above.
|
||||||
|
"rc_inspection_date": ["Inspection start date"],
|
||||||
|
```
|
||||||
|
|
||||||
|
and in the stream schema, next to the other rc properties:
|
||||||
|
|
||||||
|
```python
|
||||||
|
th.Property("rc_inspection_date", th.StringType),
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `inspection_date`'s own priority list also contains `"Inspection start date"` as a lower-priority candidate — that stays; in renewed-framework files the higher-priority OEIF column exists so they map to different columns, and in legacy files both map to the same column but rc grades are absent, and staging nulls `rc_inspection_date` in that case (Step 3).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix `discover_csv_url` to pick the newest file, not `matches[0]`**
|
||||||
|
|
||||||
|
The GOV.UK page lists 2017 files first; `matches[0]` is a 2017 CSV. Replace the body of `discover_csv_url()` to date-sort the `latest_inspections_as_at` links, mirroring `discover_independent_csv_url`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def discover_csv_url() -> str | None:
|
||||||
|
"""Scrape GOV.UK page to find the latest MI CSV download link.
|
||||||
|
|
||||||
|
The page lists a decade of monthly files, oldest first — take the
|
||||||
|
newest 'latest inspections as at <date>' link by parsing its date,
|
||||||
|
never matches[0].
|
||||||
|
"""
|
||||||
|
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
csv_links = re.findall(
|
||||||
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
months = {
|
||||||
|
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||||
|
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||||
|
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
|
||||||
|
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||||
|
}
|
||||||
|
parsed_links = []
|
||||||
|
for link in csv_links:
|
||||||
|
normalized = link.lower().replace('-', '_')
|
||||||
|
if 'latest_inspections_as_at' not in normalized:
|
||||||
|
continue
|
||||||
|
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
|
||||||
|
if match:
|
||||||
|
day, month_str, year = match.groups()
|
||||||
|
month = months.get(month_str)
|
||||||
|
if month:
|
||||||
|
try:
|
||||||
|
parsed_links.append((datetime(int(year), month, int(day)), link))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
parsed_links.sort(reverse=True)
|
||||||
|
if parsed_links:
|
||||||
|
return parsed_links[0][1]
|
||||||
|
if csv_links:
|
||||||
|
return csv_links[-1]
|
||||||
|
matches = re.findall(
|
||||||
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
```
|
||||||
|
|
||||||
|
(`mi_url` config still wins when set — `self.config.get("mi_url") or discover_csv_url()` is unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Parse and guard the date in staging**
|
||||||
|
|
||||||
|
In `stg_ofsted_inspections.sql`, inside the `renamed` CTE after the `rc_sixth_form` line, add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Start date of the latest FULL inspection (the report-card
|
||||||
|
-- inspection in the renewed framework). Guarded below: only kept
|
||||||
|
-- when the row actually carries report-card grades, because in
|
||||||
|
-- legacy-format files this column is the legacy inspection date.
|
||||||
|
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
|
||||||
|
```
|
||||||
|
|
||||||
|
and replace the final select:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select
|
||||||
|
*,
|
||||||
|
case
|
||||||
|
when rc_safeguarding_met is not null
|
||||||
|
or rc_inclusion is not null
|
||||||
|
or rc_curriculum_teaching is not null
|
||||||
|
or rc_achievement is not null
|
||||||
|
or rc_attendance_behaviour is not null
|
||||||
|
or rc_personal_development is not null
|
||||||
|
or rc_leadership_governance is not null
|
||||||
|
then rc_inspection_date_raw
|
||||||
|
end as rc_inspection_date
|
||||||
|
from renamed
|
||||||
|
where inspection_date is not null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Propagate through int + mart**
|
||||||
|
|
||||||
|
Add `rc_inspection_date,` to the explicit column lists of `int_ofsted_latest.sql` and `fact_ofsted_inspection.sql` (after `rc_sixth_form`). Do NOT propagate `rc_inspection_date_raw`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Parse-check dbt**
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .`
|
||||||
|
Expected: parse OK, no compilation errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-ofsted pipeline/transform/models
|
||||||
|
git commit -m "feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Report-card date in the API and UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/models.py` (FactOfstedInspection)
|
||||||
|
- Modify: `backend/data_loader.py` (`_ofsted_block`)
|
||||||
|
- Test: `backend/tests/test_supplementary_enrichment.py` (extend the existing `_ofsted_block` tests)
|
||||||
|
- Modify: `nextjs-app/lib/types.ts` (OfstedInspection)
|
||||||
|
- Modify: `nextjs-app/components/compare/CompareOfsted.tsx` ("Inspected" measure)
|
||||||
|
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `marts.fact_ofsted_inspection.rc_inspection_date` (Task 2).
|
||||||
|
- Produces: API `ofsted.rc_inspection_date: string | null` (ISO date). UI rule: report-card displays are dated with `rc_inspection_date` only; when null, show "—" (never the legacy date).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing backend test**
|
||||||
|
|
||||||
|
In `backend/tests/test_supplementary_enrichment.py`, alongside the existing `_ofsted_block` tests, add (reuse the file's existing fake-row helper/style):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ofsted_block_carries_rc_inspection_date():
|
||||||
|
o = _fake_ofsted_row( # use this file's existing fake/stub construction
|
||||||
|
overall_effectiveness=None,
|
||||||
|
ungraded_grade=2,
|
||||||
|
rc_achievement=1,
|
||||||
|
rc_inspection_date=date(2026, 2, 3),
|
||||||
|
inspection_date=date(2021, 10, 7),
|
||||||
|
)
|
||||||
|
block = _ofsted_block(o, 138690)
|
||||||
|
assert block["rc_inspection_date"] == "2026-02-03"
|
||||||
|
# The legacy inspection date is still present, unchanged.
|
||||||
|
assert block["inspection_date"] == "2021-10-07"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||||
|
o = _fake_ofsted_row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||||
|
block = _ofsted_block(o, 136276)
|
||||||
|
assert block["rc_inspection_date"] is None
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_supplementary_enrichment.py -q`
|
||||||
|
Expected: FAIL (KeyError / AttributeError on `rc_inspection_date`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Backend implementation**
|
||||||
|
|
||||||
|
`backend/models.py`, in `FactOfstedInspection` after `rc_sixth_form`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Start date of the report-card inspection itself (renewed framework,
|
||||||
|
# Nov 2025+). Null for rows without report-card grades.
|
||||||
|
rc_inspection_date = Column(Date)
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/data_loader.py` `_ofsted_block`, after the `"inspection_date"` entry:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"rc_inspection_date": (
|
||||||
|
o.rc_inspection_date.isoformat()
|
||||||
|
if getattr(o, "rc_inspection_date", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`getattr` default keeps old test stubs working.) Run the backend suite; expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Failing frontend test**
|
||||||
|
|
||||||
|
`nextjs-app/lib/types.ts`, in `OfstedInspection`, after `inspection_date`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||||
|
rc_inspection_date?: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `nextjs-app/__tests__/components/CompareOfsted.test.tsx`, add to the existing suite (reusing its fixture style):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
|
const ofsted = reportCardOfsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: '2026-02-03',
|
||||||
|
});
|
||||||
|
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||||
|
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||||
|
const ofsted = reportCardOfsted({ inspection_date: '2021-10-07', rc_inspection_date: null });
|
||||||
|
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(`reportCardOfsted` = the file's existing report-card fixture builder, or build inline matching its other tests.) Run just this file; expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Frontend implementation**
|
||||||
|
|
||||||
|
In `CompareOfsted.tsx`, replace the body of the "Inspected" measure's map:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{schools.map((school, i) => {
|
||||||
|
const ofsted = data[String(school.urn)]?.ofsted;
|
||||||
|
// A report card is dated by its OWN inspection date. The legacy
|
||||||
|
// inspection_date belongs to an older inspection and must never
|
||||||
|
// be shown against a report card (report cards exist only from
|
||||||
|
// Nov 2025).
|
||||||
|
const dateIso =
|
||||||
|
displays[i].kind === 'report_card'
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
const age = yearsSince(dateIso);
|
||||||
|
return (
|
||||||
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
|
{formatInspectionDate(dateIso)}{' '}
|
||||||
|
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run frontend checks**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/models.py backend/data_loader.py backend/tests nextjs-app/lib/types.ts nextjs-app/components/compare/CompareOfsted.tsx nextjs-app/__tests__/components/CompareOfsted.test.tsx
|
||||||
|
git commit -m "fix(compare): date report cards with their own inspection date, never the legacy one"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Census-based context benchmarks; kill the FSM fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pipeline/transform/models/marts/fact_census_benchmarks.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
|
||||||
|
- Modify: `backend/models.py` (new `CensusBenchmark` model)
|
||||||
|
- Modify: `backend/data_loader.py` (`compute_benchmarks`)
|
||||||
|
- Modify: `backend/app.py` (compare endpoint call site, only if the signature change requires it)
|
||||||
|
- Test: `backend/tests/test_benchmarks.py`
|
||||||
|
- Modify: `nextjs-app/components/compare/CompareCommunity.tsx:36`
|
||||||
|
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` or the community section's existing test home (add a fallback-removal test where the FSM chip logic is tested today)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `marts.fact_pupil_characteristics` (urn, year, phase_type_grouping, total_pupils, fsm_pct, eal_pct).
|
||||||
|
- Produces: `marts.fact_census_benchmarks` — one row per phase (`'primary'`/`'secondary'`), columns `phase, year, fsm_pct, eal_pct, median_pupils`. `fsm_pct`/`eal_pct` are **pupil-weighted means** (so they approximate the national pupil-level rate, answering the expert's objection to school-median anchors). API `benchmarks.{primary,secondary}` keeps its existing keys; `fsm_pct`/`eal_pct`/`median_pupils` now come from this mart; `disadvantaged_pct` becomes primary-only (the KS2-column median was junk for secondary).
|
||||||
|
|
||||||
|
- [ ] **Step 1: dbt mart**
|
||||||
|
|
||||||
|
Create `pipeline/transform/models/marts/fact_census_benchmarks.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: state-school context benchmarks from the pupil census — one row per
|
||||||
|
-- phase, latest census year. Computed at import time (never per request).
|
||||||
|
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
|
||||||
|
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
|
||||||
|
-- Consumers must label these "state-school average (computed from our
|
||||||
|
-- dataset)" (spec §8.6), never "England average".
|
||||||
|
|
||||||
|
with latest as (
|
||||||
|
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
classified as (
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when p.phase_type_grouping ilike '%primary%' then 'primary'
|
||||||
|
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
|
||||||
|
end as phase,
|
||||||
|
p.total_pupils,
|
||||||
|
p.fsm_pct,
|
||||||
|
p.eal_pct,
|
||||||
|
l.year
|
||||||
|
from {{ ref('fact_pupil_characteristics') }} p
|
||||||
|
join latest l on p.year = l.year
|
||||||
|
where p.total_pupils is not null and p.total_pupils > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
select
|
||||||
|
phase,
|
||||||
|
max(year) as year,
|
||||||
|
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
|
||||||
|
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
|
||||||
|
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
|
||||||
|
from classified
|
||||||
|
where phase is not null
|
||||||
|
group by phase
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `fact_census_benchmarks` entry to `_marts_schema.yml` in the file's existing style (name + description; column tests only if sibling marts have them).
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Failing backend test**
|
||||||
|
|
||||||
|
In `backend/tests/test_benchmarks.py` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_benchmarks_use_census_mart_for_context(monkeypatch):
|
||||||
|
census = {
|
||||||
|
"primary": {"year": 202425, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||||
|
"secondary": {"year": 202425, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||||
|
}
|
||||||
|
result = compute_benchmarks(_sample_df(), census_benchmarks=census)
|
||||||
|
assert result["primary"]["fsm_pct"] == 25.3
|
||||||
|
assert result["secondary"]["eal_pct"] == 18.9
|
||||||
|
assert result["secondary"]["median_pupils"] == 980
|
||||||
|
# KS2-only columns must not produce a fake secondary disadvantaged anchor.
|
||||||
|
assert result["secondary"]["disadvantaged_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmarks_context_none_when_mart_missing():
|
||||||
|
result = compute_benchmarks(_sample_df(), census_benchmarks=None)
|
||||||
|
assert result["primary"]["fsm_pct"] is None # never silently fall back
|
||||||
|
```
|
||||||
|
|
||||||
|
(`_sample_df()` = this file's existing dataframe fixture.) Run the file; expected: FAIL (unexpected keyword `census_benchmarks`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backend implementation**
|
||||||
|
|
||||||
|
`backend/models.py` (next to the national-average models):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CensusBenchmark(Base):
|
||||||
|
"""State-school context benchmarks from the pupil census — one row per phase."""
|
||||||
|
__tablename__ = "fact_census_benchmarks"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
phase = Column(String(20), primary_key=True)
|
||||||
|
year = Column(Integer)
|
||||||
|
fsm_pct = Column(Float) # pupil-weighted mean
|
||||||
|
eal_pct = Column(Float) # pupil-weighted mean
|
||||||
|
median_pupils = Column(Integer)
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/data_loader.py` — change the signature and `_block`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside, keep `_median` and `_weighted_disadvantaged` as-is, and replace `_block` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _block(sub, phase, with_disadvantaged):
|
||||||
|
census = (census_benchmarks or {}).get(phase) or {}
|
||||||
|
block = {
|
||||||
|
# Context measures come from the census mart (pupil-weighted):
|
||||||
|
# the performance df has no fsm_pct, and its eal/disadvantaged
|
||||||
|
# columns are KS2-only — medianing them for "secondary" produced
|
||||||
|
# junk anchors from the handful of all-through schools.
|
||||||
|
"eal_pct": census.get("eal_pct"),
|
||||||
|
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||||
|
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||||
|
"fsm_pct": census.get("fsm_pct"),
|
||||||
|
"median_pupils": census.get("median_pupils"),
|
||||||
|
}
|
||||||
|
if with_disadvantaged:
|
||||||
|
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||||
|
return block
|
||||||
|
```
|
||||||
|
|
||||||
|
and the return:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return {
|
||||||
|
"source": "state-school average (computed from our dataset)",
|
||||||
|
"year": int(latest_year),
|
||||||
|
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||||
|
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `backend/app.py`'s compare endpoint, load the mart and pass it (same defensive style as the national-averages queries):
|
||||||
|
|
||||||
|
```python
|
||||||
|
census_benchmarks = None
|
||||||
|
try:
|
||||||
|
rows = db.query(CensusBenchmark).all()
|
||||||
|
if rows:
|
||||||
|
census_benchmarks = {
|
||||||
|
r.phase: {
|
||||||
|
"year": r.year,
|
||||||
|
"fsm_pct": r.fsm_pct,
|
||||||
|
"eal_pct": r.eal_pct,
|
||||||
|
"median_pupils": r.median_pupils,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
...
|
||||||
|
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `CensusBenchmark`; use the endpoint's existing db session pattern.) Run the backend suite; expected: PASS (update any existing benchmark tests that asserted the old median-sourced fsm/eal values).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Frontend — remove the cross-definition fallback**
|
||||||
|
|
||||||
|
`nextjs-app/components/compare/CompareCommunity.tsx:36`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const anchor = bench?.fsm_pct ?? null;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the FSM chip has unit coverage, update/add the case: `anchor` null ⇒ no verdict chip rendered (bare value only). Run `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/marts backend/models.py backend/data_loader.py backend/app.py backend/tests/test_benchmarks.py nextjs-app/components/compare/CompareCommunity.tsx nextjs-app/__tests__
|
||||||
|
git commit -m "fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Official KS4 national averages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (new stream, registered in `discover_streams`)
|
||||||
|
- Create: `pipeline/transform/models/staging/stg_ees_ks4_national.sql`
|
||||||
|
- Modify: `pipeline/transform/models/staging/_stg_sources.yml` (add raw table `ees_ks4_national`)
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ks4_national_averages.sql` (rewrite)
|
||||||
|
- Modify: `backend/models.py` (Ks4NationalAverage docstring), `backend/app.py` (`_national_averages_payload` — remove the computed fallback)
|
||||||
|
- Test: `backend/tests/test_national_averages_marts.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: EES data-catalogue CSV `https://explore-education-statistics.service.gov.uk/data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv` (columns verified: `time_period, geographic_level, establishment_type_group, breakdown_topic, breakdown, attainment8_average, progress8_average, engmath_95_percent, engmath_94_percent, ebacc_entering_percent, ebacc_95_percent, ebacc_94_percent, ebacc_aps_average, …`).
|
||||||
|
- Produces: `marts.fact_ks4_national_averages` with the SAME columns as today (so `Ks4NationalAverage` needs no schema change), now holding official DfE figures; `gcse_grade_91_pct` is NULL (not in the official series — the England anchor for that measure disappears, which is correct: it was noise).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tap stream**
|
||||||
|
|
||||||
|
In `tap.py`, after the KS2 national stream, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── KS4 National Headlines (national level only — one row per year) ──────────
|
||||||
|
# Dataset: "National characteristics summary data" (Key stage 4 performance).
|
||||||
|
# Official England state-funded headline measures, 2018/19 → latest.
|
||||||
|
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
|
||||||
|
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
|
||||||
|
# not missing data.
|
||||||
|
|
||||||
|
_KS4_NATIONAL_CSV_URL = (
|
||||||
|
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
|
||||||
|
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
_KS4_NATIONAL_COL_MAP = {
|
||||||
|
"attainment8_average": "attainment_8_score",
|
||||||
|
"progress8_average": "progress_8_score",
|
||||||
|
"engmath_94_percent": "english_maths_standard_pass_pct",
|
||||||
|
"engmath_95_percent": "english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entering_percent": "ebacc_entry_pct",
|
||||||
|
"ebacc_94_percent": "ebacc_standard_pass_pct",
|
||||||
|
"ebacc_95_percent": "ebacc_strong_pass_pct",
|
||||||
|
"ebacc_aps_average": "ebacc_avg_score",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EESKs4NationalStream(Stream):
|
||||||
|
"""National KS4 headline averages — one row per academic year.
|
||||||
|
|
||||||
|
Filters to geographic_level == 'National', establishment_type_group ==
|
||||||
|
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
|
||||||
|
so only the England-wide all-pupils row per year is emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ees_ks4_national"
|
||||||
|
primary_keys = ["time_period"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("time_period", th.StringType, required=True),
|
||||||
|
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
|
||||||
|
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||||
|
df.columns = [c.strip().lower() for c in df.columns]
|
||||||
|
|
||||||
|
for col, want in [
|
||||||
|
("geographic_level", "national"),
|
||||||
|
("establishment_type_group", "all state-funded"),
|
||||||
|
("breakdown_topic", "total"),
|
||||||
|
("breakdown", "total"),
|
||||||
|
]:
|
||||||
|
if col in df.columns:
|
||||||
|
df = df[df[col].str.strip().str.lower() == want]
|
||||||
|
|
||||||
|
self.logger.info("Emitting %d national KS4 rows", len(df))
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {"time_period": row.get("time_period", "").strip()}
|
||||||
|
for src, out in _KS4_NATIONAL_COL_MAP.items():
|
||||||
|
record[out] = row.get(src, "")
|
||||||
|
yield record
|
||||||
|
```
|
||||||
|
|
||||||
|
Register `EESKs4NationalStream(self)` in `discover_streams` next to the KS2 national stream.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Raw source + staging model**
|
||||||
|
|
||||||
|
Add to `_stg_sources.yml` under the raw source, matching the `ees_ks2_national` entry's style:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: ees_ks4_national
|
||||||
|
description: Official DfE KS4 national headline averages (EES data catalogue)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `pipeline/transform/models/staging/stg_ees_ks4_national.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Staging model: official DfE KS4 national headline averages — one row per
|
||||||
|
-- academic year (England, all state-funded, all pupils). Source: EES data
|
||||||
|
-- catalogue "National characteristics summary data". Suppressed values
|
||||||
|
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
|
||||||
|
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(trim(time_period) as integer) as year,
|
||||||
|
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||||
|
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||||
|
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||||
|
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||||
|
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
|
||||||
|
from {{ source('raw', 'ees_ks4_national') }}
|
||||||
|
where time_period ~ '^[0-9]+$'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the mart**
|
||||||
|
|
||||||
|
Replace the entire body of `fact_ks4_national_averages.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
|
||||||
|
-- year (England, state-funded, all pupils), from the EES national dataset.
|
||||||
|
-- Replaces the previous unweighted school-level means, which were 7–15
|
||||||
|
-- points off every headline measure and produced an arithmetically
|
||||||
|
-- impossible national Progress 8. gcse_grade_91_pct has no official
|
||||||
|
-- national series and is NULL (schema kept for the API model).
|
||||||
|
|
||||||
|
select
|
||||||
|
year,
|
||||||
|
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,
|
||||||
|
cast(null as double precision) as gcse_grade_91_pct
|
||||||
|
from {{ ref('stg_ees_ks4_national') }}
|
||||||
|
order by year
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Backend — official provenance, no computed fallback**
|
||||||
|
|
||||||
|
`backend/models.py`: change the `Ks4NationalAverage` docstring to `"""Official DfE KS4 national headline averages — one row per academic year."""`.
|
||||||
|
|
||||||
|
`backend/app.py` `_national_averages_payload`: delete the entire `if not any(secondary_by_year.values()):` fallback block (it computes dataset means that the UI footnote then labels official). Update the function docstring's KS4 sentence to: `official DfE KS4 figures (fact_ks4_national_averages). If the KS4 mart hasn't been built yet, the secondary series is empty — never a computed stand-in, because the UI labels these figures as official.`
|
||||||
|
|
||||||
|
Update `backend/tests/test_national_averages_marts.py`: the test that exercised the fallback now asserts the opposite —
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ks4_secondary_empty_when_mart_missing(...):
|
||||||
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
|
payload = _national_averages_payload(df)
|
||||||
|
assert all(not e["secondary"] for e in payload["by_year"])
|
||||||
|
```
|
||||||
|
|
||||||
|
(adapt to the file's existing fixtures/monkeypatching). Run the backend suite — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-ees pipeline/transform backend
|
||||||
|
git commit -m "fix(data): official DfE KS4 national headline averages; drop mislabelled computed means"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Honest 2021/22 footnote
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/components/ComparisonChart.tsx:243-247`
|
||||||
|
- Modify: `nextjs-app/lib/compareChartData.ts` (comment lines 7, 53–55 — comments only, no logic)
|
||||||
|
- Modify: `nextjs-app/__tests__/lib/compareChartData.test.ts` (test name/comment wording only)
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md` §8.1
|
||||||
|
|
||||||
|
**Interfaces:** none — copy and docs only. This is the one place the plan changes reviewed copy, because the reviewed copy is factually wrong (Global Constraints exception).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix the user-facing copy**
|
||||||
|
|
||||||
|
In `ComparisonChart.tsx` replace the note:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{built.showUnpublished202122Note && (
|
||||||
|
<p className={styles.chartNote}>
|
||||||
|
No national tests were held in 2019/20 and 2020/21 (COVID), and our dataset doesn't
|
||||||
|
yet include school-level figures for 2021/22 — the England average is shown for that
|
||||||
|
year.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix the lying comments**
|
||||||
|
|
||||||
|
In `compareChartData.ts`, update the header comment (line 7) and the `showUnpublished202122Note` doc comment (lines 53–55) to say the 2021/22 school-level figures are *absent from our dataset* (DfE published them in Dec 2022; ingesting them is a backlog pipeline task), not "unpublished". Rename nothing (the flag name stays — pure rename churn). In `compareChartData.test.ts`, adjust the test description/comment wording the same way.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Correct spec §8.1**
|
||||||
|
|
||||||
|
In the spec's §8.1, replace any wording that calls 2021/22 school-level KS2 a "permanent DfE gap" with: DfE published school-level KS2 results for 2021/22 in December 2022 (with comparability caveats); they are not yet ingested — loading them remains an open pipeline task, and the chart footnote says "our dataset doesn't yet include" accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify + commit**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add nextjs-app docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md
|
||||||
|
git commit -m "fix(compare): stop attributing the missing 2021/22 school-level year to DfE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Full verification, PR, and post-deploy checklist
|
||||||
|
|
||||||
|
**Files:** none new (verification + PR).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run everything**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
|
||||||
|
cd nextjs-app && npx tsc --noEmit && npm test && cd ..
|
||||||
|
cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir . && cd ../..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open the PR**
|
||||||
|
|
||||||
|
Push `fix/compare-final-review-mustfix`; open a PR via the Gitea API using `git credential fill` basic auth (token-header auth 401s). PR body: summarize the five findings and fixes, link the expert review, end with the standard Claude Code attribution + session URL. Note in the body that findings 2 and 4 also need a **DAG run after the staging deploy** before the UI shows corrected data.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Post-merge staging verification (after the user merges and the daily DAG runs — record results, do not promote)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Report card dated by its own inspection (Barclay): expect 2026-02-03
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690" | python3 -c "import json,sys; o=json.load(sys.stdin)['comparison']['138690']['ofsted']; print(o['rc_inspection_date'], o['inspection_date'])"
|
||||||
|
# Stale Watford rc grades cleared by the fresh extract: expect report_card == {}
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['comparison']['136276']['ofsted']['report_card'])"
|
||||||
|
# Official KS4 nationals: expect A8 46.0 for 202425, progress_8_score absent
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; print(json.load(sys.stdin)['secondary'])"
|
||||||
|
# FSM benchmark real (~24-26), secondary disadvantaged_pct gone
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690,136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['benchmarks'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-screenshot both phase views (desktop + mobile, "More measures" expanded, Watford Grammar in the secondary set) and hand them to the Ofsted expert agent for the sign-off pass it said it expects. Production promotion remains the human's manual call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (expert should-fix/minor — separate follow-ups)
|
||||||
|
|
||||||
|
- 137086-style interim state (subgrades without an overall from an RI reinspection) rendering treatment (finding 6).
|
||||||
|
- Disadvantaged cohort sizes on the attainment row (finding 7, spec §8.5).
|
||||||
|
- SEN/EAL "typical school" labelling and secondary SEN benchmark (finding 8) — note Task 4 already upgrades EAL to a pupil-weighted census figure.
|
||||||
|
- Selective-school admissions copy variant (finding 9).
|
||||||
|
- Removing/relabelling `gcse_grade_91_pct` as a compare measure (finding 10) — Task 5 already removes its false England anchor.
|
||||||
|
- Palette deviation (11), trends picker label (12), "More measures" expanded-state verification (13).
|
||||||
|
- Actually ingesting the 2021/22 school-level KS2 release (the copy in Task 6 says "doesn't *yet* include").
|
||||||
@@ -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,215 @@
|
|||||||
|
# Exam Results Taxonomy — Phase Grouping and Sixth-Form Separation
|
||||||
|
|
||||||
|
**Date:** 2026-07-07
|
||||||
|
**Status:** Approved design (taxonomy/analysis only — no implementation in this doc's scope)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Classify every exam-result metric SchoolCompare displays today into four phase
|
||||||
|
groups — **Primary**, **Secondary**, **Sixth form**, **Other** — and define an
|
||||||
|
authoritative rule for separating schools that have a sixth form from those
|
||||||
|
that don't. This document is the reference for:
|
||||||
|
|
||||||
|
1. How the UI should group results sections and rankings by phase.
|
||||||
|
2. The future KS5 (A-level) ingestion work — the Sixth form group lists the
|
||||||
|
concrete DfE metrics as placeholders with source columns.
|
||||||
|
3. Replacing the fragile `age_range contains "18"` heuristic with the GIAS
|
||||||
|
`OfficialSixthForm` flag.
|
||||||
|
|
||||||
|
## 1. Grouping principle
|
||||||
|
|
||||||
|
Metrics are grouped by **the key stage of the assessment**, not by the phase
|
||||||
|
of the school displaying them. An all-through school (4–18) shows metrics in
|
||||||
|
all three exam groups; a pure primary shows only the Primary group.
|
||||||
|
|
||||||
|
| Group | Assessments | Key stage | Taken at age | Data status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Primary** | KS2 SATs (reading, writing TA, maths, GPS, science TA) | KS2 | 10–11 (Year 6) | ✅ Live — `marts.fact_ks2_performance` |
|
||||||
|
| **Secondary** | GCSEs, Attainment 8 / Progress 8, EBacc | KS4 | 15–16 (Year 11) | ✅ Live — `marts.fact_ks4_performance` |
|
||||||
|
| **Sixth form** | A levels, applied general, tech levels | KS5 (16–18) | 17–18 (Year 12–13) | ⏳ Not ingested — placeholders in §4 |
|
||||||
|
| **Other** | Non-exam context displayed alongside results | n/a | n/a | ✅ Live — various marts |
|
||||||
|
|
||||||
|
Not covered (not displayed today, candidates for future "Other"/Primary):
|
||||||
|
EYFS Good Level of Development, Year 1 Phonics check, Year 4 Multiplication
|
||||||
|
Tables Check, KS1 assessments (no longer published at school level by DfE).
|
||||||
|
|
||||||
|
## 2. Metric-by-metric mapping (current site)
|
||||||
|
|
||||||
|
Every key in `backend/schemas.py` `METRIC_DEFINITIONS` — the single source of
|
||||||
|
truth for what the site displays — mapped to its phase group. `category` is
|
||||||
|
the existing schema category; source columns are the DfE names used at
|
||||||
|
ingestion (legacy performance-tables CSV for KS2, EES for KS4).
|
||||||
|
|
||||||
|
### Primary (KS2 SATs)
|
||||||
|
|
||||||
|
| Metric key | Category | DfE source column |
|
||||||
|
|---|---|---|
|
||||||
|
| `rwm_expected_pct` | expected | `PTRWM_EXP` |
|
||||||
|
| `reading_expected_pct` | expected | `PTREAD_EXP` |
|
||||||
|
| `writing_expected_pct` | expected | `PTWRITTA_EXP` |
|
||||||
|
| `maths_expected_pct` | expected | `PTMAT_EXP` |
|
||||||
|
| `gps_expected_pct` | expected | `PTGPS_EXP` |
|
||||||
|
| `science_expected_pct` | expected | `PTSCITA_EXP` |
|
||||||
|
| `rwm_high_pct` | higher | `PTRWM_HIGH` |
|
||||||
|
| `reading_high_pct` | higher | `PTREAD_HIGH` |
|
||||||
|
| `writing_high_pct` | higher | `PTWRITTA_HIGH` |
|
||||||
|
| `maths_high_pct` | higher | `PTMAT_HIGH` |
|
||||||
|
| `gps_high_pct` | higher | `PTGPS_HIGH` |
|
||||||
|
| `reading_progress` | progress | `READPROG` |
|
||||||
|
| `writing_progress` | progress | `WRITPROG` |
|
||||||
|
| `maths_progress` | progress | `MATPROG` |
|
||||||
|
| `reading_avg_score` | average | `READ_AVERAGE` |
|
||||||
|
| `maths_avg_score` | average | `MAT_AVERAGE` |
|
||||||
|
| `gps_avg_score` | average | `GPS_AVERAGE` |
|
||||||
|
| `rwm_expected_boys_pct` | gender | `PTRWM_EXP_B` |
|
||||||
|
| `rwm_expected_girls_pct` | gender | `PTRWM_EXP_G` |
|
||||||
|
| `rwm_high_boys_pct` | gender | `PTRWM_HIGH_B` |
|
||||||
|
| `rwm_high_girls_pct` | gender | `PTRWM_HIGH_G` |
|
||||||
|
| `rwm_expected_disadvantaged_pct` | equity | `PTRWM_EXP_FSM6CLA1A` |
|
||||||
|
| `rwm_expected_non_disadvantaged_pct` | equity | `PTRWM_EXP_NotFSM6CLA1A` |
|
||||||
|
| `disadvantaged_gap` | equity | `DIFFN_RWM_EXP` |
|
||||||
|
| `reading_absence_pct` | absence | `PTREAD_AT` |
|
||||||
|
| `gps_absence_pct` | absence | `PTGPS_AT` |
|
||||||
|
| `maths_absence_pct` | absence | `PTMAT_AT` |
|
||||||
|
| `writing_absence_pct` | absence | `PTWRITTA_AD` |
|
||||||
|
| `science_absence_pct` | absence | `PTSCITA_AD` |
|
||||||
|
| `rwm_expected_3yr_pct` | trends | `PTRWM_EXP_3YR` |
|
||||||
|
| `reading_avg_3yr` | trends | `READ_AVERAGE_3YR` |
|
||||||
|
| `maths_avg_3yr` | trends | `MAT_AVERAGE_3YR` |
|
||||||
|
|
||||||
|
The absence metrics measure absence *from KS2 tests*, so they belong to
|
||||||
|
Primary even though they are not attainment scores. National comparators for
|
||||||
|
this group come from `marts.fact_ks2_national_averages`.
|
||||||
|
|
||||||
|
### Secondary (KS4 / GCSE)
|
||||||
|
|
||||||
|
| Metric key | Category | EES source column |
|
||||||
|
|---|---|---|
|
||||||
|
| `attainment_8_score` | gcse | `attainment8_average` |
|
||||||
|
| `progress_8_score` | gcse | `progress8_average` |
|
||||||
|
| `english_maths_standard_pass_pct` | gcse | `engmath_94_percent` |
|
||||||
|
| `english_maths_strong_pass_pct` | gcse | `engmath_95_percent` |
|
||||||
|
| `ebacc_entry_pct` | gcse | `ebacc_entering_percent` |
|
||||||
|
| `ebacc_standard_pass_pct` | gcse | `ebacc_94_percent` |
|
||||||
|
| `ebacc_strong_pass_pct` | gcse | `ebacc_95_percent` |
|
||||||
|
| `ebacc_avg_score` | gcse | `ebacc_aps_average` |
|
||||||
|
| `gcse_grade_91_pct` | gcse | `gcse_91_percent` |
|
||||||
|
|
||||||
|
Also stored in `marts.fact_ks4_performance` (and `fact_performance`) but not
|
||||||
|
yet in `METRIC_DEFINITIONS` — Secondary group members when surfaced:
|
||||||
|
`progress_8_lower_ci`, `progress_8_upper_ci`, `progress_8_english`,
|
||||||
|
`progress_8_maths`, `progress_8_ebacc`, `progress_8_open`,
|
||||||
|
`prior_attainment_avg` (KS2 baseline of the GCSE cohort), `sen_pct`.
|
||||||
|
|
||||||
|
### Sixth form (KS5)
|
||||||
|
|
||||||
|
No metrics today. The secondary school detail view renders a static note
|
||||||
|
("Post-16 destination data coming soon") when the school has a sixth form.
|
||||||
|
Placeholders for ingestion are specified in §4.
|
||||||
|
|
||||||
|
### Other (non-exam context)
|
||||||
|
|
||||||
|
Displayed alongside results but not tied to any assessment:
|
||||||
|
|
||||||
|
| Metric key / surface | Category | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `disadvantaged_pct` | context | KS2 CSV `PTFSM6CLA1A` |
|
||||||
|
| `eal_pct` | context | KS2 CSV `PTEALGRP2` |
|
||||||
|
| `sen_support_pct` | context | KS2 CSV `PSENELK` (KS4 fallback `sen_no_ehcp_pupil_percent`) |
|
||||||
|
| `stability_pct` | context | KS2 CSV `PTMOBN` |
|
||||||
|
| Ofsted grades incl. `sixth_form_provision` / `rc_sixth_form` | — | `marts.fact_ofsted_inspection` |
|
||||||
|
| Admissions (offers, oversubscription) | — | `marts.fact_admissions` |
|
||||||
|
| Finance (per-pupil spend, cost shares) | — | `marts.fact_finance` |
|
||||||
|
| Deprivation (IDACI) | — | `marts.fact_deprivation` |
|
||||||
|
| Pupil characteristics (census) | — | `marts.fact_pupil_characteristics` |
|
||||||
|
|
||||||
|
Note: the context metrics are cohort characteristics of the KS2 cohort at
|
||||||
|
source, but they are presented (and should stay presented) as school-level
|
||||||
|
context, so they group as Other, not Primary.
|
||||||
|
|
||||||
|
## 3. Sixth-form separation
|
||||||
|
|
||||||
|
### Definition (authoritative)
|
||||||
|
|
||||||
|
> A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` =
|
||||||
|
> `"Has a sixth form"` for its URN.
|
||||||
|
|
||||||
|
GIAS values are `Has a sixth form`, `Does not have a sixth form`, and
|
||||||
|
`Not applicable` / blank. `Not applicable` (nurseries, primaries, PRUs) maps
|
||||||
|
to **false**. This field is the DfE's registry flag, updated continuously,
|
||||||
|
and is the only source that correctly classifies:
|
||||||
|
|
||||||
|
- 16–19 sixth-form colleges and UTCs (age ranges like `14-19`, `16-19` that
|
||||||
|
the current substring heuristic misclassifies as *no* sixth form);
|
||||||
|
- schools whose statutory age range extends to 18 on paper but which have no
|
||||||
|
registered post-16 provision.
|
||||||
|
|
||||||
|
### Pipeline change (implemented 2026-07-07)
|
||||||
|
|
||||||
|
1. `stg_gias_establishments.sql`: add
|
||||||
|
`"OfficialSixthForm (name)" as official_sixth_form`.
|
||||||
|
2. `dim_school.sql` (+ `models.py` `DimSchool`, `_marts_schema.yml`): add
|
||||||
|
`has_sixth_form boolean` = `official_sixth_form = 'Has a sixth form'`.
|
||||||
|
3. Expose `has_sixth_form` on the school API payloads.
|
||||||
|
|
||||||
|
Implemented in `feat/gias-sixth-form-flag` — see
|
||||||
|
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
|
||||||
|
|
||||||
|
### Current heuristic — audit of `age_range` ~ "18" sites
|
||||||
|
|
||||||
|
All must migrate to the `has_sixth_form` flag once exposed:
|
||||||
|
|
||||||
|
| Site | Current behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `backend/app.py:419-422` | `/api/schools?has_sixth_form=yes\|no` filters on `age_range.str.contains("18")` |
|
||||||
|
| `nextjs-app/components/SecondarySchoolDetailView.tsx:101` | "Sixth form" badge + coming-soon note from `age_range?.includes('18')` |
|
||||||
|
| `nextjs-app/components/FilterBar.tsx:370-372` | Filter labels hard-code "(11-18)" / "(11-16)" — labels should drop the age-range parenthetical since sixth form ≠ age range |
|
||||||
|
|
||||||
|
Fallback rule: if GIAS is blank for a URN (rare; new establishments), fall
|
||||||
|
back to the age-range heuristic and log the URN.
|
||||||
|
|
||||||
|
### UI separation rules
|
||||||
|
|
||||||
|
- **School page**: schools with `has_sixth_form = true` show a Sixth form
|
||||||
|
results section (placeholder until KS5 data lands); schools without never
|
||||||
|
show it. Badge on the header as today, but driven by the flag.
|
||||||
|
- **Search/rankings filter**: "With sixth form" / "Without sixth form" uses
|
||||||
|
the flag; applies to secondary and all-through phases.
|
||||||
|
- **Comparison**: when comparing a with-sixth-form school against one
|
||||||
|
without, the Sixth form group renders "No sixth form" for the latter
|
||||||
|
rather than blank cells, making the structural difference explicit.
|
||||||
|
|
||||||
|
## 4. Sixth form placeholders — future KS5 ingestion spec
|
||||||
|
|
||||||
|
Source: DfE "A level and other 16 to 18 results" (EES, preferred — matches
|
||||||
|
the KS4 EES tap) or legacy performance-tables `england_ks5final.csv`.
|
||||||
|
Column names below are from the legacy KS5 CSV; verify against the EES
|
||||||
|
release chosen at ingestion time.
|
||||||
|
|
||||||
|
| Proposed metric key | Name | Legacy source column | Type |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `alevel_aps_per_entry` | A level average points per entry | `TALLPPE_ALEV_1618` | score |
|
||||||
|
| `alevel_avg_grade` | A level average grade (e.g. B-) | `TALLPPEGRD_ALEV_1618` | grade |
|
||||||
|
| `academic_aps_per_entry` | Academic qualifications APS per entry | `TALLPPE_ACAD_1618` | score |
|
||||||
|
| `applied_general_aps_per_entry` | Applied general APS per entry | `TALLPPE_AGEN_1618` | score |
|
||||||
|
| `tech_level_aps_per_entry` | Tech level APS per entry | `TALLPPE_TLEV_1618` | score |
|
||||||
|
| `english_progress_1618` | English progress (16–18, unfinished GCSE 4+) | `PROGENG_1618` | score |
|
||||||
|
| `maths_progress_1618` | Maths progress (16–18) | `PROGMAT_1618` | score |
|
||||||
|
| `ks5_cohort_size` | Students at end of 16–18 study | `TALLPUP_1618` | count |
|
||||||
|
| `alevel_3plus_aab_pct` | % achieving AAB+ in ≥2 facilitating subjects | `TAAB2FAC_1618` | percentage |
|
||||||
|
| `ks5_retention_pct` | Retention (completed main programme) | study-programme retention measure | percentage |
|
||||||
|
| `ks5_destinations_pct` | Sustained education/employment destination | 16–18 destination measures dataset | percentage |
|
||||||
|
|
||||||
|
Proposed landing shape mirrors KS4: `stg_ees_ks5.sql` →
|
||||||
|
`int_ks5_with_lineage.sql` → `marts.fact_ks5_performance` (one row per URN
|
||||||
|
per year), joined into `fact_performance`, with a `category: "sixth_form"`
|
||||||
|
(or `"alevel"`) block added to `METRIC_DEFINITIONS`.
|
||||||
|
|
||||||
|
## 5. Out of scope
|
||||||
|
|
||||||
|
- Any implementation (pipeline, API, or UI changes) — this is the taxonomy
|
||||||
|
reference; implementation work items are §3 "Pipeline change", the
|
||||||
|
heuristic migration audit, and §4 ingestion, each to be planned separately.
|
||||||
|
- Middle schools (deemed secondary/primary): they follow the assessment-based
|
||||||
|
grouping automatically — no special casing.
|
||||||
|
- Independent schools: no DfE performance data published; unaffected.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# GIAS Code Dictionaries — Codes in Marts, Names in Code
|
||||||
|
|
||||||
|
**Date:** 2026-07-09
|
||||||
|
**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Six GIAS classification fields are stored in the marts as repeated name
|
||||||
|
strings. Replace them with the official DfE integer codes and translate
|
||||||
|
code → name in application code. After this change the marts carry only
|
||||||
|
codes for:
|
||||||
|
|
||||||
|
| GIAS field | Today (marts, string) | After (marts, int) |
|
||||||
|
|---|---|---|
|
||||||
|
| `TypeOfEstablishment (name)` | `dim_school.school_type` | `school_type_code` |
|
||||||
|
| `EstablishmentStatus (name)` | `dim_school.status` | `status_code` |
|
||||||
|
| `PhaseOfEducation (name)` | `dim_school.phase` | `phase_code` |
|
||||||
|
| `OfficialSixthForm (name)` | (already reduced to `has_sixth_form` bool) | `official_sixth_form_code` in staging only; mart keeps the bool |
|
||||||
|
| `ReligiousCharacter (name)` | `dim_school.religious_character` | `religious_character_code` |
|
||||||
|
| `AdmissionsPolicy (name)` | `dim_school.admissions_policy` | `admissions_policy_code` |
|
||||||
|
|
||||||
|
Motivation: smaller marts and stable enum values for filtering. (Honest
|
||||||
|
sizing note: at ~25k open schools the raw performance win is modest; the
|
||||||
|
durable benefits are storage, DfE-governed vocabulary, and filter values
|
||||||
|
that can't drift with GIAS renames.)
|
||||||
|
|
||||||
|
## Decisions (made during brainstorming)
|
||||||
|
|
||||||
|
1. **GIAS native codes**, not custom enums. The GIAS bulk CSV publishes an
|
||||||
|
official `X (code)` column beside every `X (name)` column. We ingest the
|
||||||
|
DfE's own codes; no invented mapping to maintain.
|
||||||
|
2. **Translation lives in the backend at the API boundary.** The API keeps
|
||||||
|
serving today's name strings; the frontend, e2e journeys, and API
|
||||||
|
consumers are untouched.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Tap (Singer schema)
|
||||||
|
|
||||||
|
Add the six `(code)` columns to `GIASEstablishmentsStream.schema` in
|
||||||
|
`pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
"TypeOfEstablishment (code)", "EstablishmentStatus (code)",
|
||||||
|
"PhaseOfEducation (code)", "OfficialSixthForm (code)",
|
||||||
|
"ReligiousCharacter (code)", "AdmissionsPolicy (code)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `(name)` columns **stay declared** — raw keeps both so we can detect
|
||||||
|
dictionary drift (§4) and regenerate dictionaries from live data.
|
||||||
|
|
||||||
|
### 2. Staging (`stg_gias_establishments.sql`)
|
||||||
|
|
||||||
|
- Add int casts: `school_type_code`, `status_code`, `phase_code`,
|
||||||
|
`official_sixth_form_code`, `religious_character_code`,
|
||||||
|
`admissions_policy_code` (all `cast(nullif(trim(...), '') as integer)`).
|
||||||
|
- Remove the corresponding name columns from the staging select
|
||||||
|
(`school_type`, `status`, `phase`, `official_sixth_form`,
|
||||||
|
`religious_character`, `admissions_policy`). Names live only in raw.
|
||||||
|
|
||||||
|
### 3. Marts
|
||||||
|
|
||||||
|
**`dim_school`** stores codes only:
|
||||||
|
|
||||||
|
- `school_type_code`, `status_code`, `phase_code`,
|
||||||
|
`religious_character_code`, `admissions_policy_code` replace their
|
||||||
|
string columns.
|
||||||
|
- Status filter becomes `where status_code in (<open>, <proposed-to-close>)`.
|
||||||
|
The numeric values are read from live raw data at implementation time
|
||||||
|
(`select distinct "EstablishmentStatus (code)", "EstablishmentStatus (name)"`),
|
||||||
|
never assumed from memory. Same filter in `dim_location`.
|
||||||
|
- `has_sixth_form` derives from `official_sixth_form_code`
|
||||||
|
(`<has-code>` → true, `<does-not>/<not-applicable>` → false, null →
|
||||||
|
`statutory_high_age >= 18` fallback). The `lower(trim(...))` string guard
|
||||||
|
becomes obsolete and is removed.
|
||||||
|
- `phase_code` derivation keeps today's cascade but emits codes:
|
||||||
|
1. GIAS `phase_code` when it is a real value (not the not-applicable code);
|
||||||
|
2. statutory-age inference emits the matching GIAS code
|
||||||
|
(Primary / Secondary / All-through — numeric values confirmed from
|
||||||
|
live data at implementation);
|
||||||
|
3. school-name heuristics (unchanged — they match `school_name`, which is
|
||||||
|
not one of the six fields) emit the same codes;
|
||||||
|
4. else null.
|
||||||
|
- dbt schema tests: `accepted_values` (severity **warn**) on every code
|
||||||
|
column, values taken from the dictionary; `not_null` warn on `phase_code`
|
||||||
|
(mirrors today's phase test); `has_sixth_form` tests unchanged.
|
||||||
|
|
||||||
|
**`dim_location`**: only the status filter changes (must stay byte-identical
|
||||||
|
to `dim_school`'s — the API inner-joins the two).
|
||||||
|
|
||||||
|
### 4. Dictionaries
|
||||||
|
|
||||||
|
**Canonical module: `backend/gias_codes.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
ESTABLISHMENT_STATUS: dict[int, str]
|
||||||
|
SCHOOL_TYPE: dict[int, str]
|
||||||
|
PHASE_OF_EDUCATION: dict[int, str]
|
||||||
|
OFFICIAL_SIXTH_FORM: dict[int, str]
|
||||||
|
RELIGIOUS_CHARACTER: dict[int, str]
|
||||||
|
ADMISSIONS_POLICY: dict[int, str]
|
||||||
|
|
||||||
|
def translate(code: int | None, mapping: dict[int, str]) -> str | None:
|
||||||
|
"""None -> None; unknown code -> 'Unknown (<code>)' + warning log."""
|
||||||
|
```
|
||||||
|
|
||||||
|
- Contents are generated from live raw data
|
||||||
|
(`SELECT DISTINCT code, name FROM raw.gias_establishments ...` per field)
|
||||||
|
and sanity-checked against the DfE GIAS registers. Names must be
|
||||||
|
byte-identical to what the API serves today.
|
||||||
|
- Unknown codes never blank the UI: `translate` returns `"Unknown (<code>)"`
|
||||||
|
and logs, so a new DfE value degrades gracefully.
|
||||||
|
|
||||||
|
**Pipeline copy: `pipeline/scripts/gias_codes.py`**
|
||||||
|
|
||||||
|
The app and pipeline Docker images have disjoint build contexts
|
||||||
|
(`Dockerfile` copies `backend/`; `pipeline/Dockerfile` copies `pipeline/`),
|
||||||
|
so the Typesense sync cannot import the backend module. It gets a
|
||||||
|
byte-identical copy, and a backend unit test asserts
|
||||||
|
`backend/gias_codes.py` and `pipeline/scripts/gias_codes.py` have identical
|
||||||
|
content — drift fails CI. (Deliberately chosen over codegen: six dicts do
|
||||||
|
not justify build machinery.)
|
||||||
|
|
||||||
|
**Seed for drift detection: `pipeline/transform/seeds/gias_code_names.csv`**
|
||||||
|
|
||||||
|
Columns `field,code,name` mirroring the dictionary. A dbt test (severity
|
||||||
|
warn) compares live raw `(code, name)` pairs against the seed; when DfE adds
|
||||||
|
or renames a value the nightly run warns, prompting a dictionary + seed
|
||||||
|
update in one PR.
|
||||||
|
|
||||||
|
### 5. Backend translation (API contract unchanged)
|
||||||
|
|
||||||
|
- `_MAIN_QUERY` selects the code columns instead of the name columns.
|
||||||
|
- `load_school_data_as_dataframe()` translates immediately after
|
||||||
|
`pd.read_sql`, writing today's column names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
df["phase"] = df["phase_code"].map(...)
|
||||||
|
df["school_type"] = df["school_type_code"].map(...) # then normalize_school_type as today
|
||||||
|
df["status"] = df["status_code"].map(...)
|
||||||
|
df["religious_denomination"] = df["religious_character_code"].map(...)
|
||||||
|
df["admissions_policy"] = df["admissions_policy_code"].map(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything downstream — `PHASE_GROUPS`, filters, payload builders,
|
||||||
|
`/api/filters`, frontend, e2e — sees exactly today's strings. No frontend
|
||||||
|
changes.
|
||||||
|
|
||||||
|
- `backend/models.py` `DimSchool`: string columns replaced by
|
||||||
|
`*_code = Column(Integer)`.
|
||||||
|
|
||||||
|
### 6. Typesense sync
|
||||||
|
|
||||||
|
`pipeline/scripts/sync_typesense.py` selects `phase`, `school_type`,
|
||||||
|
`religious_character` today. It switches to the code columns and translates
|
||||||
|
via `pipeline/scripts/gias_codes.py` before indexing, so facet values in
|
||||||
|
search are unchanged.
|
||||||
|
|
||||||
|
### 7. Rollout
|
||||||
|
|
||||||
|
- No DB migration: marts are full-rebuild tables.
|
||||||
|
- Deploy window: until the first post-merge pipeline run, the old marts
|
||||||
|
still carry string columns while the new backend queries code columns, so
|
||||||
|
the backend's query fails and it serves empty data (the one-column retry
|
||||||
|
built for `has_sixth_form` doesn't generalise to six columns, and a full
|
||||||
|
old-schema fallback query isn't worth it). **Decision: accept the window
|
||||||
|
and close it operationally — the runbook is merge → deploy → trigger
|
||||||
|
`school_data_daily` immediately.** The DAG's final step already calls
|
||||||
|
`/api/admin/reload`, so the backend recovers without a restart.
|
||||||
|
- Tests: backend unit tests for `translate()` (known / unknown / None),
|
||||||
|
payload tests asserting names still served, the file-parity test, dbt
|
||||||
|
schema/seed tests. Frontend: no changes; existing Jest suite is the
|
||||||
|
regression net.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Recoding other string columns (`gender`, `urban_rural`,
|
||||||
|
`nursery_provision`, `local_authority_name` …) — same pattern can follow
|
||||||
|
later if this proves out.
|
||||||
|
- Collapsing academy subtypes (today's `normalize_school_type`) — kept
|
||||||
|
as-is, applied after translation.
|
||||||
|
- Serving codes through the API — the contract deliberately keeps names.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Compare Screen Redesign — Expert Data Review
|
||||||
|
|
||||||
|
**Date:** 2026-07-11
|
||||||
|
**Reviewer:** subagent briefed as an English education-standards / DfE-Ofsted data expert
|
||||||
|
**Subject:** desktop + mobile compare mockups and the redesign spec
|
||||||
|
(`2026-07-11-compare-screen-redesign-design.md`)
|
||||||
|
**Status:** first-pass must-fixes applied 2026-07-12; second-pass
|
||||||
|
findings (below) applied 2026-07-12 — mockups + spec §4/§8 updated
|
||||||
|
|
||||||
|
## Must-fix
|
||||||
|
|
||||||
|
1. **COVID gap is wrong and drops a real results year.** KS2 tests were
|
||||||
|
cancelled 2019/20 and 2020/21 only; they resumed in 2021/22 with
|
||||||
|
published school-level results (England RWM ≈ 59%). The mockup charts
|
||||||
|
omit 2021/22 entirely and the tooltip claims no tests were held
|
||||||
|
2019/20–2021/22. Fix: add 2021/22 to axis and all series; shrink the
|
||||||
|
gap band; optionally annotate 2021/22 with DfE's post-pandemic
|
||||||
|
comparability caution.
|
||||||
|
2. **Report-card at-a-glance summary miscounts areas.** Detail list has
|
||||||
|
4 Strong / 2 Expected / 1 Attention needed + Safeguarding met, but
|
||||||
|
the summary says "3 areas Expected standard" — it counts safeguarding
|
||||||
|
as a graded area. Safeguarding is a separate binary judgement and
|
||||||
|
must be excluded from rating counts.
|
||||||
|
3. **"Where the offers went" derivation is unsound.** Places − 1st-pref
|
||||||
|
offers ≠ "second or third choices": the residual can include 4th–6th
|
||||||
|
preference offers (pan-London scheme) and LA-allocated children who
|
||||||
|
didn't choose the school; and offers don't necessarily equal PAN.
|
||||||
|
Use the real 2nd/3rd-preference fields being promoted from
|
||||||
|
`raw.ees_admissions`; until then drop the row.
|
||||||
|
4. **Ofsted timeline in the copy is wrong.** Overall grades were
|
||||||
|
abolished September 2024, not November 2025; Sept 2024–Nov 2025
|
||||||
|
inspections kept the four key judgements without an overall grade
|
||||||
|
(ungraded inspections carried grades forward). Neither mockup shows
|
||||||
|
the interim regime, which will dominate real comparisons. Fix copy
|
||||||
|
and add an interim example.
|
||||||
|
5. **Barclay's "published an overall grade only — no area-by-area
|
||||||
|
detail" misdescribes inspections.** No inspection type does that; a
|
||||||
|
2021 graded inspection necessarily had subgrades — the gap is in our
|
||||||
|
dataset. If it was an ungraded (s8) inspection, "Outstanding" is a
|
||||||
|
carried-forward grade and should say so. Fix: "We don't hold
|
||||||
|
area-by-area detail for this inspection", and distinguish graded vs
|
||||||
|
ungraded in the data model.
|
||||||
|
|
||||||
|
## Should-fix
|
||||||
|
|
||||||
|
6. Writing is teacher assessment, not a test — "national tests and
|
||||||
|
teacher assessments"; note TA caveat on the Writing strip.
|
||||||
|
7. Verify renewed-framework wording against Ofsted's final toolkit:
|
||||||
|
likely "Needs attention" (not "Attention needed") and "Personal
|
||||||
|
development and well-being" (which otherwise collides with the
|
||||||
|
identically-named legacy judgement). Pin every label to the
|
||||||
|
published toolkit.
|
||||||
|
8. "Expected standard" now means two things on one page (Ofsted area
|
||||||
|
rating vs KS2 measure) — disambiguate in tooltips.
|
||||||
|
9. Disadvantaged row: DfE definition includes looked-after / previously
|
||||||
|
looked-after children, not just FSM6; benchmark labels inconsistent
|
||||||
|
across desktop/mobile; subgroup percentages need cohort sizes or a
|
||||||
|
volatility threshold before chips are attached.
|
||||||
|
10. "Trend, last 7 years" spans ten years; sparklines render the COVID
|
||||||
|
gap as equal spacing (the exact defect the audit criticises) and
|
||||||
|
"Improved: 52% → 87%" endpoint-cherry-picks a volatile series.
|
||||||
|
11. At-a-glance "Getting a place" uses different metrics per school
|
||||||
|
(Barclay is also oversubscribed on total preferences but shows a
|
||||||
|
green chip). Standardise on first-preference success %. Explain the
|
||||||
|
equal-preference rule; condition "living close by matters" on the
|
||||||
|
school's actual oversubscription criteria.
|
||||||
|
12. "457 applications for 180 places" = total preferences at any rank,
|
||||||
|
not head-to-head applicants; lead with first preferences vs places.
|
||||||
|
Add offers-vs-final-intake (waiting lists/appeals) caveat.
|
||||||
|
13. Elmhurst's subgrade list is likely missing Early years provision
|
||||||
|
(school has a nursery) — possible pipeline gap.
|
||||||
|
14. "Ofsted rating" label is obsolete post-Sept-2024 — use "Latest
|
||||||
|
Ofsted inspection"; check whether Oct 2021 is the latest inspection
|
||||||
|
or merely the latest graded one.
|
||||||
|
15. SEN: "EHCP plans" is redundant; 28% SEN support often indicates
|
||||||
|
resourced provision — add a note; England SEN-support ≈ 14%, not 13%.
|
||||||
|
|
||||||
|
## Nice-to-have
|
||||||
|
|
||||||
|
16. Consistent labelling of official DfE vs dataset-computed benchmarks
|
||||||
|
(and medians shouldn't be called averages inconsistently).
|
||||||
|
17. England 2015/16 RWM (53%) exists in DfE publications — the null is
|
||||||
|
a dataset gap; source it or the England line looks broken.
|
||||||
|
18. "1 in 4 first choices missed out" — actually more than 1 in 4.
|
||||||
|
19. "1,273 of 1,260 places (full)" is over capacity; capacity figures
|
||||||
|
are often stale — say "at or above capacity".
|
||||||
|
20. State the actual suppression rule (DfE: ≤5 pupils suppressed,
|
||||||
|
small numbers rounded) instead of "a handful".
|
||||||
|
21. Spec §4.3 progress chips can't exist for displayed years: KS2
|
||||||
|
progress ended with 2022/23 (no KS1 baseline) and returns
|
||||||
|
~2027/28 with the reception baseline. Make explicit in the spec.
|
||||||
|
IDACI (spec §4.5) is absent from mockups; if shipped, caveat it
|
||||||
|
describes pupils' neighbourhoods, not the school.
|
||||||
|
22. Tooltips should give the official term "first preference" alongside
|
||||||
|
the plain-English "first choice".
|
||||||
|
|
||||||
|
## Overall assessment (verbatim gist)
|
||||||
|
|
||||||
|
The bones are genuinely good by education-data standards —
|
||||||
|
England-average anchoring, explicit non-comparability messaging across
|
||||||
|
Ofsted regimes, refusal to synthesise an overall grade, time-true
|
||||||
|
x-axis, neutral FSM/EAL framing — better than most commercial
|
||||||
|
school-comparison sites. But items 1–5 are outright factual errors or
|
||||||
|
misdescriptions that a well-informed parent or Ofsted would catch;
|
||||||
|
the admissions section needs the most conceptual work (equal
|
||||||
|
preference, preferences-vs-applicants, offers-vs-intake). Fix 1–5
|
||||||
|
before user testing; the rest fold into the planned PRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Second-pass review (2026-07-12)
|
||||||
|
|
||||||
|
Same reviewer, after the must-fixes and the new three-tier metric
|
||||||
|
exposure model were applied.
|
||||||
|
|
||||||
|
## Verification of first-pass must-fixes
|
||||||
|
|
||||||
|
- **1 (COVID/2021/22): resolved.** Time-true axis, band covers only the
|
||||||
|
cancelled years, England 58.7% consistent with official figures,
|
||||||
|
dataset gaps break lines honestly; reading/maths England series all
|
||||||
|
match published figures; RWM ≤ min(subject) checks pass.
|
||||||
|
- **2 (report-card count): resolved** — safeguarding excluded, spec §8.2.
|
||||||
|
- **3 (offers derivation): resolved** — row removed, spec §8.3 bans it.
|
||||||
|
- **4 (Ofsted timeline): resolved on desktop; mobile omits the interim
|
||||||
|
regime clause** (see finding 6).
|
||||||
|
- **5 (Barclay explanation): resolved.**
|
||||||
|
|
||||||
|
## New findings
|
||||||
|
|
||||||
|
1. **Should-fix — scaled-score strip domain contradicts caption.**
|
||||||
|
Caption says "scaled scores run 80–120", strips render 100–120;
|
||||||
|
truncated domain exaggerates small gaps and below-100 averages
|
||||||
|
would fall off the edge. Render 80–120, or caption the 100–120
|
||||||
|
window honestly and define below-100 behaviour.
|
||||||
|
2. **Should-fix — scaled-score England ticks (106/105/105) unsourced.**
|
||||||
|
Plausible but hand-entered; verify against DfE 2024/25 tables and
|
||||||
|
add loading official England scaled scores to the pipeline list
|
||||||
|
(absent from §8.1/§8.6).
|
||||||
|
3. **Should-fix — "Writing" listed under "Higher standard" in the
|
||||||
|
picker.** Writing TA outcome is "greater depth" (GDS), never
|
||||||
|
"higher standard". Label "Writing — greater depth (teacher
|
||||||
|
assessment)"; tooltip the combined higher-standard composition.
|
||||||
|
4. Nice — "grammar & punctuation" summary line drops "spelling" (GPS).
|
||||||
|
5. Nice — science is teacher-assessed (no KS2 test since 2009) and
|
||||||
|
coarse; tooltip it like writing; reconsider its tier-2 slot.
|
||||||
|
6. **Should-fix — mobile Ofsted copy skips the interim regime**
|
||||||
|
(Sept 2024–Nov 2025) that desktop explains. One clause fixes it.
|
||||||
|
7. **Should-fix — benchmark provenance still inconsistent** (EAL
|
||||||
|
tooltip unsourced; FSM/disadvantaged chips vs tooltips use three
|
||||||
|
vocabularies; header note says all England averages are official).
|
||||||
|
Adopt one house style: official = "England average", computed =
|
||||||
|
"benchmark / typical state school (our dataset)". Also tighten EAL
|
||||||
|
definition to census wording ("first language known or believed to
|
||||||
|
be other than English").
|
||||||
|
8. Nice — "community primaries" distance note attached to an academy
|
||||||
|
(Elmhurst); say "non-faith primaries" or condition on policy field.
|
||||||
|
9. Nice — "Improving since 2022" → "since 2022/23".
|
||||||
|
10. Nice — England chart tooltips show decimals; §7 mandates whole
|
||||||
|
percents.
|
||||||
|
|
||||||
|
## Residual gaps not covered by spec §8
|
||||||
|
|
||||||
|
11. Spec promises IDACI-in-words, Attendance section, and tier-2
|
||||||
|
gender/absence that the mockups never show — mark post-v1 or
|
||||||
|
demonstrate, so implementation scope is unambiguous.
|
||||||
|
12. Add official England scaled-score averages to the pipeline task
|
||||||
|
list.
|
||||||
|
13. Add the writing/greater-depth terminology rule to §8.7.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
All must-fixes genuinely resolved; the tier model is conceptually
|
||||||
|
sound ("no measure is lost", honest dataset-gap breaks, grouped
|
||||||
|
picker). Remaining issues are contained: one internal contradiction
|
||||||
|
(80–120 vs 100–120), one provenance inconsistency, one terminology
|
||||||
|
error (writing/GDS). With findings 1–3 and 6–7 addressed, the data
|
||||||
|
framing is fit to put in front of parents.
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Compare Screen Redesign — Audit & Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-11
|
||||||
|
**Status:** Draft — awaiting review
|
||||||
|
**Scope:** `/compare` page (nextjs-app), `/api/compare` endpoint (backend)
|
||||||
|
|
||||||
|
## 1. Audit of the current screen
|
||||||
|
|
||||||
|
The current compare page (`nextjs-app/components/ComparisonView.tsx`) is a
|
||||||
|
single-metric analyst tool: a `<select>` with ~40 KS2/GCSE metrics, one
|
||||||
|
line chart over time, and a year-by-year table — all for the one selected
|
||||||
|
metric. Observed on production with 3 primary schools:
|
||||||
|
|
||||||
|
**What works**
|
||||||
|
|
||||||
|
- URL-shareable state (`?urns=…&metric=…`), native share sheet.
|
||||||
|
- Phase tabs (primary/secondary) with sensible auto-detection.
|
||||||
|
- Colour-coded school cards tied to chart series.
|
||||||
|
- Metric descriptions from `/api/metrics` (single source of truth).
|
||||||
|
|
||||||
|
**What doesn't**
|
||||||
|
|
||||||
|
1. **Performance-only.** The database already holds Ofsted inspections,
|
||||||
|
admissions/oversubscription history, pupil characteristics (FSM/EAL),
|
||||||
|
SEN, deprivation (IDACI), finance, capacity, faith, gender, trust —
|
||||||
|
none of it reaches the compare screen. `/api/compare` returns only
|
||||||
|
`yearly_data` + minimal `school_info`, while `/api/schools/{urn}`
|
||||||
|
already returns all supplementary blocks.
|
||||||
|
2. **One metric at a time.** A parent must know which of ~40 metrics
|
||||||
|
matters, select each in turn, and hold results in their head. There is
|
||||||
|
no side-by-side overview and no way to see two dimensions at once.
|
||||||
|
3. **No benchmarks.** Numbers float without anchors: is 79% RWM good?
|
||||||
|
The DB has official national averages (`fact_ks2_national_averages`)
|
||||||
|
but the page never shows them.
|
||||||
|
4. **Domain jargon untranslated.** "GPS Expected %", "Progress scores",
|
||||||
|
"RWM Combined" assume DfE literacy. The only plain-English help is one
|
||||||
|
note for progress scores.
|
||||||
|
5. **Raw numbers, no judgement support.** 87.0% vs 92.0% vs 79.0% — the
|
||||||
|
page never says "all three are well above the England average of 62%",
|
||||||
|
which is the fact a parent actually needs.
|
||||||
|
6. **Bugs/paper cuts observed:** the third school's series did not render
|
||||||
|
on the production chart despite table data (worth a separate fix);
|
||||||
|
the COVID gap (2018/19 → 2022/23) renders as equal spacing with no
|
||||||
|
annotation; table shows "87.0%" precision that implies false accuracy.
|
||||||
|
|
||||||
|
## 2. Data inventory (available vs shown)
|
||||||
|
|
||||||
|
| Domain | Source table | On detail page | On compare |
|
||||||
|
|---|---|---|---|
|
||||||
|
| KS2 attainment/progress | fact_ks2_performance | yes | **yes** (only thing shown) |
|
||||||
|
| National averages | fact_ks2_national_averages | partial | no |
|
||||||
|
| Ofsted (latest + subgrades + report-card fields) | fact_ofsted_inspection, dim_school | yes | no |
|
||||||
|
| Admissions & oversubscription (multi-year) | fact_admissions | yes | no |
|
||||||
|
| Pupil characteristics (FSM, EAL, gender split) | fact_pupil_characteristics | yes | no |
|
||||||
|
| Context (SEN, disadvantaged, stability, absence) | fact_ks2_performance | via metric picker | buried in picker |
|
||||||
|
| Deprivation (IDACI) | fact_deprivation | yes | no |
|
||||||
|
| Finance (per-pupil spend) | fact_finance | yes | no |
|
||||||
|
| School facts (capacity, faith, ages, trust, nursery, gender) | dim_school | yes | no |
|
||||||
|
| Location/distance | dim_location | map | no |
|
||||||
|
|
||||||
|
## 3. Design goals
|
||||||
|
|
||||||
|
1. **Answer parent questions, in order:** Is it a good school (Ofsted)?
|
||||||
|
Do children do well there (academics vs England)? Will my child get a
|
||||||
|
place (admissions)? What is the school like (size, community, faith)?
|
||||||
|
2. **Every number gets an anchor** — the England average, rendered as a
|
||||||
|
consistent visual tick, plus a plain-English chip
|
||||||
|
(Above / Close to / Below England average).
|
||||||
|
3. **Plain English first, jargon on demand.** Labels are questions or
|
||||||
|
sentences ("Children reaching the expected standard in reading,
|
||||||
|
writing and maths"), codes/acronyms live in tooltips.
|
||||||
|
4. **Scan whole-picture first, drill down second.** The single-metric
|
||||||
|
trend explorer survives, demoted to an "Explore trends" section at the
|
||||||
|
bottom rather than being the entire page.
|
||||||
|
|
||||||
|
## 4. Proposed structure
|
||||||
|
|
||||||
|
Columns = schools (max 4 visible on desktop, horizontal scroll beyond),
|
||||||
|
rows = dimensions. Sticky compact school header keeps column identity
|
||||||
|
while scrolling. Sections, in order:
|
||||||
|
|
||||||
|
1. **At a glance** — verdict row per school: Ofsted badge, headline
|
||||||
|
attainment vs England (dot strip + chip), oversubscription chip,
|
||||||
|
size, distance (when a location is set).
|
||||||
|
2. **Ofsted inspection** — must handle all three inspection regimes,
|
||||||
|
which will coexist in comparisons for years:
|
||||||
|
- **Legacy graded (pre-Sept 2024):** overall grade badge
|
||||||
|
(Outstanding/Good/Requires improvement/Inadequate). Subgrades,
|
||||||
|
where published, are rendered in the **same area-by-rating chip
|
||||||
|
list UX as report cards** (one row per judgement area, rating as
|
||||||
|
a chip) — one visual grammar for inspection detail across both
|
||||||
|
regimes. Where our dataset has no subgrades for an inspection,
|
||||||
|
say so honestly ("We don't hold area-by-area detail for this
|
||||||
|
inspection") and point to the school's Ofsted page — never claim
|
||||||
|
the inspection itself published no detail (graded inspections
|
||||||
|
always have subgrades; if it was ungraded, the grade is
|
||||||
|
carried forward and must be labelled as such).
|
||||||
|
- **Interim ungraded (Sept 2024 – Nov 2025):** parsed outcome
|
||||||
|
("remains Good") shown as the effective grade, marked as such.
|
||||||
|
- **Renewed framework report card (from Nov 2025):** no overall
|
||||||
|
grade exists. Render the report card as an area-by-rating list
|
||||||
|
using Ofsted's 5-point scale (Exceptional / Strong standard /
|
||||||
|
Expected standard / Attention needed / Urgent improvement) across
|
||||||
|
the evaluation areas we model (`rc_inclusion`,
|
||||||
|
`rc_curriculum_teaching`, `rc_achievement`,
|
||||||
|
`rc_attendance_behaviour`, `rc_personal_development`,
|
||||||
|
`rc_leadership_governance`, `rc_early_years`, `rc_sixth_form`)
|
||||||
|
plus the separate safeguarding met/not-met flag. **At-a-glance
|
||||||
|
summary rule:** never an unlabelled colour strip — summarise by
|
||||||
|
counting areas per rating, best first ("5 areas Strong standard ·
|
||||||
|
3 areas Expected standard"), and always name any area rated
|
||||||
|
Attention needed or Urgent improvement explicitly (never fold
|
||||||
|
problems into a count), plus "Safeguarding not met" whenever that
|
||||||
|
flag is false. When everything is Expected standard or better,
|
||||||
|
add the reassurance line "No areas need attention".
|
||||||
|
When a comparison mixes regimes, show a one-line comparability note
|
||||||
|
("Ofsted changed how it reports in Nov 2025 — a report card and an
|
||||||
|
older overall grade aren't directly comparable"). Never derive a
|
||||||
|
fake overall grade from report-card areas.
|
||||||
|
3. **Academics (KS2)** — one dot-strip row per headline measure (RWM
|
||||||
|
expected, RWM higher, reading/writing/maths expected), each with the
|
||||||
|
England-average tick and per-school dots; copy must say "tests and
|
||||||
|
teacher assessments" (writing is TA, not a test). Progress scores
|
||||||
|
translated to Above/Average/Below chips (CI-based) — **but note KS2
|
||||||
|
progress measures ended with 2022/23** (no KS1 baseline afterwards)
|
||||||
|
and return only when the reception-baseline cohort reaches Y6
|
||||||
|
(~2027/28), so progress chips apply to historical years in the
|
||||||
|
trends explorer, not the headline view. Sparkline per school over
|
||||||
|
the full published period, with an honest gap for the cancelled
|
||||||
|
test years (2019/20–2020/21). Disadvantaged-pupils row under an
|
||||||
|
"Equity" subheading, always with cohort size shown and DfE's full
|
||||||
|
definition (FSM6 **or** looked-after/previously looked-after).
|
||||||
|
4. **Getting a place** — oversubscription ratio as plain sentence
|
||||||
|
("184 applications for 80 places"), first-preference success %, trend
|
||||||
|
vs last year, admissions policy.
|
||||||
|
5. **Who goes there** — pupils on roll (vs capacity), boys/girls, FSM %,
|
||||||
|
EAL %, SEN support %, faith, ages, nursery, trust. *Post-v1:* IDACI
|
||||||
|
decile in words (needs a coverage check of `fact_deprivation` and
|
||||||
|
the neighbourhood-not-school caveat, §8.7).
|
||||||
|
6. **Attendance** — *post-v1.* The KS2 test-day absence fields are the
|
||||||
|
only per-school absence data we hold; they're near-zero for most
|
||||||
|
schools and easy to misread as general attendance. Ship only if a
|
||||||
|
general-absence source lands.
|
||||||
|
7. **Explore trends** (existing feature, collapsed) — metric picker +
|
||||||
|
multi-year line chart + table, with an added England-average
|
||||||
|
reference line and a COVID-gap annotation.
|
||||||
|
|
||||||
|
**Metric exposure model (three tiers).** No measure from the current
|
||||||
|
page is lost; they surface at three levels of prominence:
|
||||||
|
- **Tier 1 — headline strips (always visible):** RWM expected,
|
||||||
|
reading/writing/maths expected, RWM higher standard.
|
||||||
|
- **Tier 2 — "More measures" expansion inside Academics:** GPS and
|
||||||
|
science expected % (science labelled teacher-assessed), average
|
||||||
|
scaled scores (reading/maths/GPS, same dot-strip grammar showing
|
||||||
|
the 100–120 window of the 80–120 scale, widening below 100, with
|
||||||
|
the England tick) — one tap/click away, same visual language.
|
||||||
|
*Post-v1:* gender split and absence (see §4.6).
|
||||||
|
- **Tier 3 — Explore trends:** the full grouped catalogue (the
|
||||||
|
current page's ~40 metrics, including equity and school-context
|
||||||
|
measures, and the GCSE set for secondary phase) drives the
|
||||||
|
year-by-year chart and table via the grouped metric picker.
|
||||||
|
The tier assignment is a content decision per phase (secondary:
|
||||||
|
Attainment 8, Progress 8 banding, grade 5+ English & maths as tier 1;
|
||||||
|
EBacc and subject entries as tier 2).
|
||||||
|
|
||||||
|
Finance (per-pupil spend) is deliberately deferred: low parent value,
|
||||||
|
risk of misreading. Revisit later.
|
||||||
|
|
||||||
|
**Mobile (design target — mobile first):** the desktop grid is the
|
||||||
|
adaptation, not the other way round. On mobile the layout goes
|
||||||
|
*measure-first*: each row is one measure with all schools listed under
|
||||||
|
it (colour dot + short name + value + chip), so comparison never
|
||||||
|
requires horizontal swiping between school cards. A sticky horizontal
|
||||||
|
school-chip bar keeps identity and add/remove available while
|
||||||
|
scrolling. Dot strips already read measure-first and carry over
|
||||||
|
unchanged. The trend chart scrolls horizontally inside its container.
|
||||||
|
|
||||||
|
## 5. Data strategy — existing dataset only
|
||||||
|
|
||||||
|
Constraint (agreed 2026-07-11): use only data already in marts plus
|
||||||
|
fields already present in the `raw` schema extracts we pull today.
|
||||||
|
No new external sources.
|
||||||
|
|
||||||
|
**Gaps in the mockup, resolved within this constraint:**
|
||||||
|
|
||||||
|
| Mockup element | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| England average for disadvantaged pupils | Compute from our own data: `stg_ees_ks2` already pivots the Disadvantaged breakdown per school; aggregate it (weighted by eligible pupils) into `fact_ks2_national_averages` or compute in the API. Label it "England average (state schools)". |
|
||||||
|
| England context for FSM / EAL / SEN chips | Compute dataset-wide medians per phase, same pattern as `/api/national-averages` does for KS4. |
|
||||||
|
| "Much larger than average" size label | Dataset median pupils-on-roll per phase. |
|
||||||
|
| Ofsted link | We don't have deep links to the latest report, so always link to the school's Ofsted provider page, `https://reports.ofsted.gov.uk/provider/21/{urn}`, derived from URN (label it "the school's Ofsted page", not "the report"). |
|
||||||
|
|
||||||
|
**Raw fields we already pull but don't store — promote to marts (one
|
||||||
|
dbt/pipeline PR, no tap changes):**
|
||||||
|
|
||||||
|
- `raw.ees_admissions`: 2nd/3rd preference applications and offers,
|
||||||
|
total-preference counts, cross-LA applications and offers → richer
|
||||||
|
"Getting a place" (e.g. "offers reached 2nd-choice families",
|
||||||
|
competition from outside the borough).
|
||||||
|
- `raw.ees_ks2_attainment`: progress-measure confidence intervals and
|
||||||
|
"working towards" % → lets the Above/Average/Below progress chips be
|
||||||
|
statistically honest (band by CI overlap with 0, mirroring DfE
|
||||||
|
methodology) instead of thresholding the point estimate.
|
||||||
|
- `raw.ees_ks4_performance` / `ees_ks4_info`: `progress8_banding`
|
||||||
|
(DfE's own plain-English "well above average … well below average"
|
||||||
|
label — exactly the chip we want for secondary), EBacc entry/APS,
|
||||||
|
grade-5+ English & maths, `attainment8_diffn`/`progress8_diffn`
|
||||||
|
(disadvantage gaps) → the secondary-phase version of the Academics
|
||||||
|
section.
|
||||||
|
- `raw.ees_census`: young-carer % and the ethnicity breakdown →
|
||||||
|
optional "Who goes there" enrichment; hold for a later iteration
|
||||||
|
(presentation needs care), but the data requires no new extract.
|
||||||
|
- `raw.ofsted_inspections` / tap-uk-ofsted: the `rc_*` report-card
|
||||||
|
columns exist in staging/marts but are stubbed `null` — the tap has a
|
||||||
|
TODO to map the report-card column names from the Ofsted MI file
|
||||||
|
(same monthly extract we already download; inspections from Nov 2025
|
||||||
|
onward carry them). This is the one promotion that needs a small tap
|
||||||
|
schema addition, and it's a prerequisite for the new-framework Ofsted
|
||||||
|
display above.
|
||||||
|
|
||||||
|
Explicitly out (not in any current extract): school-level phonics,
|
||||||
|
workforce/teacher data, per-school attendance beyond the KS2 test-day
|
||||||
|
absence fields, Ofsted report-card documents themselves.
|
||||||
|
|
||||||
|
## 6. API changes
|
||||||
|
|
||||||
|
Extend `GET /api/compare` response per URN with the same supplementary
|
||||||
|
blocks the detail endpoint already builds (`get_supplementary_data`):
|
||||||
|
`ofsted`, `census`, `admissions` (+ `admissions_history`), `deprivation`,
|
||||||
|
plus a top-level `national_averages` block for the latest year. Reuse the
|
||||||
|
existing function; no new tables. Response stays backward-compatible
|
||||||
|
(additive fields only). Add derived helper fields server-side or compute
|
||||||
|
chips client-side from `national_averages` (client-side preferred — no
|
||||||
|
schema churn).
|
||||||
|
|
||||||
|
## 7. Accessibility & comprehension devices
|
||||||
|
|
||||||
|
- Verdict chips are text + colour + position (never colour alone).
|
||||||
|
- Every acronym has a tooltip using existing `MetricTooltip`.
|
||||||
|
- "How to read this" one-liner at the top of each section.
|
||||||
|
- Chart palette: coral `#e07256`, teal `#00949b`, purple `#8664c9`
|
||||||
|
(validated: lightness band, chroma, CVD separation, contrast — the
|
||||||
|
current `--chart-2/-4` tokens fail chroma/contrast checks and should
|
||||||
|
be nudged to these).
|
||||||
|
- Numbers rounded to whole percents; England tick labelled on first use.
|
||||||
|
|
||||||
|
## 8. Expert-review requirements
|
||||||
|
|
||||||
|
An adversarial review by an education-data expert (full findings in
|
||||||
|
`2026-07-11-compare-screen-expert-review.md`) was applied to the
|
||||||
|
mockups on 2026-07-12. The following are binding requirements for
|
||||||
|
implementation, beyond what the mockups can show:
|
||||||
|
|
||||||
|
1. **Chart truthfulness:** KS2 tests were cancelled 2019/20–2020/21
|
||||||
|
only. **2021/22 school-level figures are a permanent source gap** —
|
||||||
|
DfE stated it would not publish KS2 2021/22 in performance tables
|
||||||
|
(verified 2026-07-12 against EES, the CSP download service, and
|
||||||
|
DfE release notes; see `# TASK 6 VERIFICATION` in
|
||||||
|
`pipeline/scripts/diagnose_compare_gaps.py`; re-verified 2026-07-16
|
||||||
|
after an expert-review challenge — the GOV.UK statistics announcement
|
||||||
|
"Primary school performance tables: 2022" is marked CANCELLED with
|
||||||
|
"will not be published in key stage 2 performance tables in academic
|
||||||
|
year 2021/22", so the footnote's "DfE didn't publish" claim stands
|
||||||
|
and must not be softened to "not in our dataset"). The chart's England-
|
||||||
|
only 2021/22 point with broken school lines is therefore the
|
||||||
|
correct permanent rendering; copy should say "DfE didn't publish
|
||||||
|
school-level figures for 2021/22", not "not in our dataset yet".
|
||||||
|
The 2015/16 national figure and the GPS/science/scaled-score
|
||||||
|
England averages ARE loadable (mapping already correct; refreshed
|
||||||
|
raw extract backfills them). Never render missing years as if time
|
||||||
|
were continuous.
|
||||||
|
2. **Report-card summaries** count graded areas only — safeguarding is
|
||||||
|
a separate binary flag, never included in rating counts.
|
||||||
|
3. **Admissions:** use the real preference-breakdown fields from
|
||||||
|
`raw.ees_admissions`; never derive "lower-preference offers" as
|
||||||
|
places − first-preference offers. Frame total applications as
|
||||||
|
"named on N forms" (any rank), lead with first-preference success,
|
||||||
|
and standardise at-a-glance chips on that one metric. Explain the
|
||||||
|
equal-preference rule; caveat offers vs final intake (waiting
|
||||||
|
lists/appeals); condition "distance decides" on the school's actual
|
||||||
|
oversubscription criteria where we have the admissions-policy field.
|
||||||
|
4. **Ofsted:** overall grades ended September 2024 (report cards from
|
||||||
|
November 2025); the interim regime must be renderable. Distinguish
|
||||||
|
graded (s5) vs ungraded (s8) inspections and surface carried-forward
|
||||||
|
grades as such; "we don't hold the detail" is a statement about our
|
||||||
|
dataset, never about the inspection. Verify every scale/area label
|
||||||
|
against Ofsted's final published toolkit before launch (e.g. "Needs
|
||||||
|
attention" vs "Attention needed"; "Personal development and
|
||||||
|
well-being" vs the identically-named legacy judgement). Check
|
||||||
|
whether a school's latest inspection is merely its latest *graded*
|
||||||
|
one. Confirm Early years provision subgrades flow through the
|
||||||
|
pipeline for schools with nurseries.
|
||||||
|
5. **Subgroup honesty:** disadvantaged-pupil percentages carry cohort
|
||||||
|
sizes and follow the DfE suppression rule (≤5 pupils suppressed);
|
||||||
|
state the rule verbatim in the footer.
|
||||||
|
6. **Benchmark provenance:** official DfE figures and
|
||||||
|
dataset-computed benchmarks must be labelled distinctly and
|
||||||
|
consistently everywhere (a computed median is a "benchmark",
|
||||||
|
not an "England average").
|
||||||
|
7. **Copy details:** "Latest Ofsted inspection" (not "Ofsted rating");
|
||||||
|
"EHC plans"; SEN-support benchmark ≈14%; high SEN share may
|
||||||
|
indicate resourced provision (say so neutrally); "at or above
|
||||||
|
capacity" rather than "full" (capacity data is often stale);
|
||||||
|
disambiguate Ofsted's "Expected standard" from the KS2 measure;
|
||||||
|
give official terms ("first preference") alongside plain English.
|
||||||
|
Writing has no "higher standard" — its TA outcome is "greater
|
||||||
|
depth (GDS)"; never list writing under a higher-standard group.
|
||||||
|
Science and writing are teacher-assessed and must be labelled as
|
||||||
|
such (no KS2 science test since 2009). House style for benchmark
|
||||||
|
provenance: official DfE figures say "England average"; computed
|
||||||
|
figures say "state-school average (computed from our dataset)" —
|
||||||
|
applied to every chip, tooltip, header note and section intro.
|
||||||
|
EAL uses the census wording: first language known or believed to
|
||||||
|
be other than English. If IDACI ships, caveat that it describes
|
||||||
|
pupils' home neighbourhoods, not the school.
|
||||||
|
|
||||||
|
## 9. Rollout
|
||||||
|
|
||||||
|
1. **PR 1 (backend):** extend `/api/compare` + tests.
|
||||||
|
2. **PR 2 (frontend):** new compare layout behind the existing route;
|
||||||
|
e2e journey updated in the same PR (promotion gate).
|
||||||
|
3. **Fix separately:** missing third series on the current chart.
|
||||||
|
|
||||||
|
## 10. Open questions for review
|
||||||
|
|
||||||
|
- Max schools: keep 10 in API but cap visible columns at 4 with scroll?
|
||||||
|
- Should distance-from-home appear when the user searched by postcode
|
||||||
|
(data exists via `dim_location`)?
|
||||||
|
- Keep finance out of v1? (Recommended: yes, out.)
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
<title>Compare screen — proposed redesign</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #faf7f2;
|
||||||
|
--bg-2: #f3ede4;
|
||||||
|
--card: #ffffff;
|
||||||
|
--ink: #1a1612;
|
||||||
|
--ink-2: #5c564d;
|
||||||
|
--ink-3: #6d685f;
|
||||||
|
--border: #e5dfd5;
|
||||||
|
--accent: #b04a2e;
|
||||||
|
--accent-bg: rgba(224, 114, 86, 0.12);
|
||||||
|
/* Validated series palette (passes lightness / chroma / CVD / contrast) */
|
||||||
|
--s1: #e07256; --s1-text: #b04a2e;
|
||||||
|
--s2: #00949b; --s2-text: #006a70;
|
||||||
|
--s3: #8664c9; --s3-text: #6a4bab;
|
||||||
|
--eng: #6d685f;
|
||||||
|
--good-bg: #e3efe6; --good-text: #1a6b34;
|
||||||
|
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
|
||||||
|
--bad-bg: #f7e3de; --bad-text: #a03a20;
|
||||||
|
--neutral-bg: #efeae1; --neutral-text: #5c564d;
|
||||||
|
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||||
|
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
|
||||||
|
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
|
||||||
|
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
|
||||||
|
--s1: #f08b6e; --s1-text: #f4a58e;
|
||||||
|
--s2: #2fb8ae; --s2-text: #5fd0c8;
|
||||||
|
--s3: #a98fe0; --s3-text: #c0abec;
|
||||||
|
--eng: #9a927f;
|
||||||
|
--good-bg: #1e3325; --good-text: #7fd39a;
|
||||||
|
--warn-bg: #38301a; --warn-text: #e4c268;
|
||||||
|
--bad-bg: #3d221b; --bad-text: #f0977f;
|
||||||
|
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
|
||||||
|
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
|
||||||
|
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
|
||||||
|
--s1: #f08b6e; --s1-text: #f4a58e;
|
||||||
|
--s2: #2fb8ae; --s2-text: #5fd0c8;
|
||||||
|
--s3: #a98fe0; --s3-text: #c0abec;
|
||||||
|
--eng: #9a927f;
|
||||||
|
--good-bg: #1e3325; --good-text: #7fd39a;
|
||||||
|
--warn-bg: #38301a; --warn-text: #e4c268;
|
||||||
|
--bad-bg: #3d221b; --bad-text: #f0977f;
|
||||||
|
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
|
||||||
|
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
|
||||||
|
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
|
||||||
|
--s1: #e07256; --s1-text: #b04a2e;
|
||||||
|
--s2: #00949b; --s2-text: #006a70;
|
||||||
|
--s3: #8664c9; --s3-text: #6a4bab;
|
||||||
|
--eng: #6d685f;
|
||||||
|
--good-bg: #e3efe6; --good-text: #1a6b34;
|
||||||
|
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
|
||||||
|
--bad-bg: #f7e3de; --bad-text: #a03a20;
|
||||||
|
--neutral-bg: #efeae1; --neutral-text: #5c564d;
|
||||||
|
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--bg); color: var(--ink); font-family: var(--body); line-height: 1.5; }
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
|
||||||
|
|
||||||
|
.mock-note {
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem; font-size: 0.85rem; color: var(--ink-2); margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.mock-note strong { color: var(--accent); }
|
||||||
|
|
||||||
|
h1 { font-family: var(--display); font-size: 2.4rem; font-weight: 700; margin: 0 0 0.25rem; text-wrap: balance; }
|
||||||
|
.sub { color: var(--ink-2); margin: 0 0 2rem; max-width: 60ch; }
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-family: var(--display); font-size: 1.45rem; font-weight: 700;
|
||||||
|
margin: 0; padding-left: 0.75rem; border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.how { font-size: 0.85rem; color: var(--ink-3); margin: 0.35rem 0 0 0.95rem; max-width: 70ch; }
|
||||||
|
section.block { margin-top: 3rem; }
|
||||||
|
|
||||||
|
/* ---- Sticky school header ---- */
|
||||||
|
.school-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
background: var(--bg); padding: 0.75rem 0; border-bottom: 1px solid var(--border);
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr) auto; gap: 0.75rem; align-items: stretch;
|
||||||
|
}
|
||||||
|
.school-chip {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow); padding: 0.55rem 0.75rem; display: flex; gap: 0.55rem; align-items: center;
|
||||||
|
border-top: 3px solid var(--chip);
|
||||||
|
}
|
||||||
|
.school-chip .dot { width: 11px; height: 11px; border-radius: 50%; background: var(--chip); flex: none; }
|
||||||
|
.school-chip .nm { font-weight: 600; font-size: 0.92rem; line-height: 1.25; }
|
||||||
|
.school-chip .la { font-size: 0.78rem; color: var(--ink-3); }
|
||||||
|
.school-chip .x {
|
||||||
|
margin-left: auto; border: none; background: var(--bg-2); color: var(--ink-3);
|
||||||
|
border-radius: 50%; width: 22px; height: 22px; cursor: pointer; flex: none; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.add-school {
|
||||||
|
border: 1.5px dashed var(--border); border-radius: 8px; background: none;
|
||||||
|
color: var(--accent); font-weight: 600; padding: 0 1rem; cursor: pointer; font-family: inherit; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Comparison grid rows ---- */
|
||||||
|
.grid { display: grid; grid-template-columns: 200px repeat(3, 1fr); gap: 0 0.75rem; margin-top: 1.25rem; }
|
||||||
|
.grid .rowlabel {
|
||||||
|
font-size: 0.85rem; font-weight: 600; color: var(--ink-2); padding: 0.85rem 0.5rem 0.85rem 0;
|
||||||
|
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.grid .cell { padding: 0.85rem 0.25rem; border-bottom: 1px solid var(--border); font-size: 0.95rem; }
|
||||||
|
.grid .cell .big { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.grid .cell .small { display: block; font-size: 0.8rem; color: var(--ink-3); margin-top: 0.1rem; }
|
||||||
|
|
||||||
|
.help {
|
||||||
|
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.65rem;
|
||||||
|
align-items: center; justify-content: center; cursor: help; flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-block; font-size: 0.75rem; font-weight: 600; border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.6rem; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip.good { background: var(--good-bg); color: var(--good-text); }
|
||||||
|
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
|
||||||
|
.chip.bad { background: var(--bad-bg); color: var(--bad-text); }
|
||||||
|
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||||
|
|
||||||
|
.ofsted-badge {
|
||||||
|
display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.25rem 0.7rem; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
/* New-framework report card */
|
||||||
|
.rc-list { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.2rem; }
|
||||||
|
.rc-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
|
||||||
|
.rc-row .a { color: var(--ink-2); }
|
||||||
|
.illus {
|
||||||
|
display: inline-block; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em;
|
||||||
|
color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0.05rem 0.4rem;
|
||||||
|
}
|
||||||
|
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
|
||||||
|
.ofsted-2 { background: #e7f0e3; color: #3c6b2f; }
|
||||||
|
:root[data-theme="dark"] .ofsted-2 { background:#25321f; color:#a5cf94; }
|
||||||
|
@media (prefers-color-scheme: dark) { .ofsted-2 { background:#25321f; color:#a5cf94; } }
|
||||||
|
|
||||||
|
/* ---- Dot strips (signature element) ---- */
|
||||||
|
.strip-row { margin: 1.1rem 0 1.6rem; }
|
||||||
|
.strip-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.strip-head .t { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.strip-head .eng-note { font-size: 0.8rem; color: var(--ink-3); }
|
||||||
|
.strip { position: relative; height: 34px; margin-top: 0.45rem; }
|
||||||
|
.strip .track { position: absolute; left: 0; right: 0; top: 15px; height: 4px; border-radius: 2px; background: var(--bg-2); }
|
||||||
|
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 26px; background: var(--eng); }
|
||||||
|
.strip .eng-lbl { position: absolute; top: -14px; transform: translateX(-50%); font-size: 0.7rem; color: var(--ink-3); white-space: nowrap; }
|
||||||
|
.strip .pt {
|
||||||
|
position: absolute; top: 9px; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.strip .pt-lbl { position: absolute; top: 27px; transform: translateX(-50%); font-size: 0.72rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
details.more-measures { margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.75rem; }
|
||||||
|
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.88rem; color: var(--accent); }
|
||||||
|
.strip-note { font-size: 0.78rem; color: var(--ink-3); margin: 0.5rem 0 0; }
|
||||||
|
.metric-picker { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.metric-picker label { font-size: 0.85rem; font-weight: 600; color: var(--ink-2); }
|
||||||
|
.metric-picker select {
|
||||||
|
font-family: inherit; font-size: 0.9rem; padding: 0.4rem 0.6rem; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border); background: var(--card); color: var(--ink); max-width: 100%;
|
||||||
|
}
|
||||||
|
.picker-note { font-size: 0.78rem; color: var(--ink-3); }
|
||||||
|
.legend { display: flex; gap: 1.1rem; flex-wrap: wrap; font-size: 0.82rem; color: var(--ink-2); margin: 0.75rem 0 0; }
|
||||||
|
.legend .li { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.legend .sw { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.legend .engsw { width: 2px; height: 12px; background: var(--eng); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow); padding: 1.25rem 1.5rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sparklines & chart ---- */
|
||||||
|
.spark { display: block; }
|
||||||
|
.chart-wrap { overflow-x: auto; }
|
||||||
|
.covid { fill: var(--bg-2); }
|
||||||
|
.covid-lbl { font-size: 0.7rem; fill: var(--ink-3); }
|
||||||
|
|
||||||
|
.barmini { height: 8px; border-radius: 4px; background: var(--bg-2); overflow: hidden; margin-top: 0.3rem; max-width: 140px; }
|
||||||
|
.barmini > i { display: block; height: 100%; border-radius: 4px; }
|
||||||
|
|
||||||
|
details.explore { margin-top: 1rem; }
|
||||||
|
details.explore summary {
|
||||||
|
cursor: pointer; font-weight: 600; color: var(--accent);
|
||||||
|
padding: 0.85rem 1.1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
}
|
||||||
|
details.explore[open] summary { border-radius: 8px 8px 0 0; }
|
||||||
|
details.explore .inner { border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px; background: var(--card); padding: 1.25rem 1.5rem; }
|
||||||
|
|
||||||
|
.footnote { font-size: 0.78rem; color: var(--ink-3); margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1rem; max-width: 75ch; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.school-bar { grid-template-columns: 1fr; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.grid .rowlabel { border-bottom: none; padding-bottom: 0.15rem; background: var(--bg-2); border-radius: 6px; padding: 0.4rem 0.6rem; margin-top: 0.8rem; }
|
||||||
|
.grid .cell { border-bottom: none; padding: 0.4rem 0.6rem; }
|
||||||
|
.grid .cell::before { content: attr(data-school); display: block; font-size: 0.72rem; font-weight: 600; color: var(--sc, var(--ink-3)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="mock-note"><strong>Mockup — proposed redesign of /compare.</strong> All figures are live production data for three real schools (2024/25 results, 2026/27 admissions round). England averages for test results are official DfE figures; other benchmarks are state-school averages computed from our dataset.</p>
|
||||||
|
|
||||||
|
<h1>Compare schools</h1>
|
||||||
|
<p class="sub">Three schools side by side — inspection results, academics, admissions and community, each anchored against the England average so you can tell at a glance what's typical and what stands out.</p>
|
||||||
|
|
||||||
|
<div class="school-bar" aria-label="Schools in this comparison">
|
||||||
|
<div class="school-chip" style="--chip: var(--s1)">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span><span class="nm">Barclay Primary School</span><br><span class="la">Waltham Forest · Academy</span></span>
|
||||||
|
<button class="x" aria-label="Remove Barclay Primary School">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="school-chip" style="--chip: var(--s2)">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span><span class="nm">Elmhurst Primary School</span><br><span class="la">Newham · Academy</span></span>
|
||||||
|
<button class="x" aria-label="Remove Elmhurst Primary School">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="school-chip" style="--chip: var(--s3)">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span><span class="nm">Plumcroft Primary School</span><br><span class="la">Greenwich · Community school</span></span>
|
||||||
|
<button class="x" aria-label="Remove Plumcroft Primary School">×</button>
|
||||||
|
</div>
|
||||||
|
<button class="add-school">+ Add school</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ AT A GLANCE ============ -->
|
||||||
|
<section class="block" style="margin-top:2rem">
|
||||||
|
<h2 class="section-title">At a glance</h2>
|
||||||
|
<p class="how">The short version — each row below is explained in its own section further down.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="rowlabel">Latest Ofsted inspection</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
|
||||||
|
<strong style="font-size:0.9rem">Report card</strong> <span class="illus">illustrative</span>
|
||||||
|
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.3rem">
|
||||||
|
<span class="chip good">4 areas Strong standard</span>
|
||||||
|
<span class="chip neutral">2 areas Expected standard</span>
|
||||||
|
<span class="chip warn">Attendance & behaviour: Attention needed</span>
|
||||||
|
</div>
|
||||||
|
<span class="small">Safeguarding met · Nov 2025</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25). England average: 62%.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="color:var(--s1-text)">87%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="color:var(--s2-text)">92%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="color:var(--s3-text)">79%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Getting a place</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="chip good">97% of first choices offered</span><span class="small">Named on 457 forms · 180 places</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="chip warn">73% of first choices offered</span><span class="small">Named on 342 forms · 120 places</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="chip good">All first choices offered</span><span class="small">Named on 185 forms · 80 places</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Size</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 pupils<span class="small">Much larger than average</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 pupils<span class="small">Much larger than average</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 pupils<span class="small">Much larger than average</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ OFSTED ============ -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">Ofsted inspection</h2>
|
||||||
|
<p class="how">Ofsted is the schools inspectorate. It stopped giving a single overall grade in <strong>September 2024</strong>; inspections between then and November 2025 kept the area-by-area judgements without an overall grade, and from <strong>November 2025</strong> new inspections produce a <strong>report card</strong> rating each area of school life on a five-point scale (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older overall grade aren't directly comparable — Plumcroft's report card below is an <em>illustrative example</em> of the new format, as no school in our dataset has one yet. (Ofsted's "Expected standard" rating is unrelated to the KS2 "expected standard" test measure further down this page.)</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="rowlabel">Result</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>Report card</strong> <span class="illus">illustrative</span><span class="small">New-style inspection — no overall grade is given</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Inspected</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">7 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">6 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">14 Nov 2025</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Judgement detail <span class="help" title="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">We don't hold area-by-area detail for this inspection — see Barclay's Ofsted page for the full report.</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">
|
||||||
|
<div class="rc-list">
|
||||||
|
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Behaviour & attitudes</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Leadership & management</span><span class="chip good">Outstanding</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
|
||||||
|
<div class="rc-list">
|
||||||
|
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Curriculum & teaching</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Attendance & behaviour</span><span class="chip warn">Attention needed</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Leadership & governance</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Ofsted page <span class="help" title="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Barclay's Ofsted page →</a></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Elmhurst's Ofsted page →</a></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Plumcroft's Ofsted page →</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ ACADEMICS ============ -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">How children do academically</h2>
|
||||||
|
<p class="how">Results from national tests and teacher assessments at the end of Year 6 (2024/25) — writing is assessed by teachers, not tested. Each line runs from 0–100%; the grey tick marks the England average, so dots to its right are above average.</p>
|
||||||
|
<div class="card">
|
||||||
|
<div id="strips"></div>
|
||||||
|
<details class="more-measures">
|
||||||
|
<summary>More measures — grammar, punctuation & spelling, science, average scaled scores</summary>
|
||||||
|
<div id="strips-more"></div>
|
||||||
|
<p class="strip-note">The strips show the 100–120 window of the full 80–120 scaled-score range; 100 is the expected standard, and the strip widens if a school averages below it. England ticks for grammar, punctuation & spelling and science aren't in our dataset yet, and the scaled-score England ticks are indicative — official DfE figures for all of these will be loaded before launch.</p>
|
||||||
|
</details>
|
||||||
|
<div class="legend" id="strip-legend"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="rowlabel">Trend, 2015/16 to 2024/25 <span class="help" title="% reaching the expected standard in reading, writing and maths each year. Tests were cancelled in 2019/20 and 2020/21 (COVID); 2021/22 school figures aren't in our dataset yet — the line breaks over those years.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><svg class="spark" data-series="52,76,65,87,87,75,87" data-color="s1" width="150" height="40" role="img" aria-label="Barclay trend: variable, most recently 87%"></svg><span class="small">Variable, recently 87%</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><svg class="spark" data-series="80,81,86,88,88,88,92" data-color="s2" width="150" height="40" role="img" aria-label="Elmhurst trend: 80% rising to 92%"></svg><span class="small">Consistently high</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><svg class="spark" data-series="58,64,57,69,62,72,79" data-color="s3" width="150" height="40" role="img" aria-label="Plumcroft trend: 58% rising to 79%"></svg><span class="small">Improving since 2022/23</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Children from lower-income families <span class="help" title="% of disadvantaged pupils (eligible for free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="font-size:1.1rem">86%</span> <span class="chip good">Well above the 46% state-school average</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="font-size:1.1rem">93%</span> <span class="chip good">Well above the 46% state-school average</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="font-size:1.1rem">72%</span> <span class="chip good">Above the 46% state-school average</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ ADMISSIONS ============ -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">Getting a place</h2>
|
||||||
|
<p class="how">From the most recent admissions round (September 2026 entry). "First choice" means families who ranked the school top of their application form — officially a "first preference". Schools never see your ranking: places are decided only by the school's admission criteria, so listing a school lower down never hurts your chances. These are National Offer Day offers — waiting lists and appeals can change the final intake.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="rowlabel">Interest in the school <span class="help" title="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Named on <span class="big" style="font-size:1.1rem">457</span> forms · <strong>180</strong> places</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">Named on <span class="big" style="font-size:1.1rem">342</span> forms · <strong>120</strong> places</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Named on <span class="big" style="font-size:1.1rem">185</span> forms · <strong>80</strong> places</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">First-choice families offered a place</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><strong>97%</strong><span class="barmini"><i style="width:97%;background:var(--s1)"></i></span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><strong>73%</strong> <span class="chip warn">Over 1 in 4 first choices missed out</span><span class="barmini"><i style="width:73%;background:var(--s2)"></i></span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>100%</strong><span class="barmini"><i style="width:100%;background:var(--s3)"></i></span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">What this means</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">Nearly every family who put Barclay first got a place.</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="small">More first-choice applications than places — check the school's admission criteria (for most non-faith primaries, distance decides).</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="small">Every family who put Plumcroft first got a place.</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ COMMUNITY ============ -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">Who goes there</h2>
|
||||||
|
<p class="how">The school's community, from the latest school census (2025/26). England averages are shown for context — there's no "right" number here.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="rowlabel">Pupils on roll</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 <span class="small">1,260 places — at or above capacity</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 <span class="small">of 996 places (98% full)</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 <span class="small">1,050 places — at or above capacity</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Girls / boys</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">51% / 49%</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">48% / 52%</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">51% / 49%</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Free school meals <span class="help" title="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. State-school average: 25% (computed from our dataset).">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">26% <span class="chip neutral">About the state-school average</span></div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">25% <span class="chip neutral">About the state-school average</span></div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">30% <span class="chip neutral">A little above average</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">English as an additional language <span class="help" title="% of pupils whose first language is known or believed to be other than English. State-school average: 22% (computed from our dataset).">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">62%</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">84%</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">20%</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Extra learning support (SEN) <span class="help" title="% of pupils receiving SEN support (not including EHC plans). State-school average: 14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">6%</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">8%</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">28% <span class="chip neutral">Well above average</span></div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Faith character</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">None</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">None</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">None</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Ages · nursery</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">3–11 · has a nursery</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">3–11 · has a nursery</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">3–11 · has a nursery</div>
|
||||||
|
|
||||||
|
<div class="rowlabel">Run by</div>
|
||||||
|
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Lion Academy Trust</div>
|
||||||
|
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">New Vision Trust</div>
|
||||||
|
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Greenwich council</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ EXPLORE TRENDS ============ -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">Explore trends</h2>
|
||||||
|
<p class="how">The full year-by-year explorer — every measure from the current compare page lives on here, grouped, each with its England-average line. Three measures are wired up in this mockup; the rest are shown to convey the catalogue.</p>
|
||||||
|
<details class="explore" open>
|
||||||
|
<summary>Year-by-year trends, 2015/16 to 2024/25</summary>
|
||||||
|
<div class="inner chart-wrap">
|
||||||
|
<div class="metric-picker">
|
||||||
|
<label for="metric-select">Measure:</label>
|
||||||
|
<select id="metric-select">
|
||||||
|
<optgroup label="Expected standard">
|
||||||
|
<option value="rwm" selected>Reading, writing & maths (combined)</option>
|
||||||
|
<option value="reading">Reading</option>
|
||||||
|
<option value="maths">Maths</option>
|
||||||
|
<option disabled>Writing (teacher assessment)</option>
|
||||||
|
<option disabled>Grammar, punctuation & spelling</option>
|
||||||
|
<option disabled>Science</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Higher standard">
|
||||||
|
<option disabled>Reading, writing & maths (combined)</option>
|
||||||
|
<option disabled>Reading · Maths · GPS</option>
|
||||||
|
<option disabled>Writing — greater depth (teacher assessment)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Average scaled scores">
|
||||||
|
<option disabled>Reading · Maths · GPS</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Equity">
|
||||||
|
<option disabled>Disadvantaged pupils — expected standard</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="School context">
|
||||||
|
<option disabled>Free school meals % · EAL % · SEN support %</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<span class="picker-note">School lines break where a year isn't in our dataset.</span>
|
||||||
|
</div>
|
||||||
|
<svg id="trendchart" width="880" height="300" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
|
||||||
|
<div class="legend" id="trend-legend"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="footnote">
|
||||||
|
Sources: DfE Compare School Performance (KS2 results), Ofsted inspection outcomes, DfE school admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are the official DfE national figures; benchmarks for free school meals, language, SEN, school size and disadvantaged pupils' results are computed across all state schools in our dataset. Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". This is a static mockup: tooltips and "Add school" are illustrative, and Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023) — no school in our dataset has a report card yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||||
|
|
||||||
|
const schools = [
|
||||||
|
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
|
||||||
|
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
|
||||||
|
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---- Dot strips ---- */
|
||||||
|
const strips = [
|
||||||
|
{ label: 'Reading, writing & maths — expected standard', eng: 62, vals: [87, 92, 79] },
|
||||||
|
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
|
||||||
|
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
|
||||||
|
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
|
||||||
|
{ label: 'Working at a higher standard than expected', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generalised strip renderer: any domain (percentages or scaled scores),
|
||||||
|
// England tick optional (omitted when the benchmark isn't in our dataset).
|
||||||
|
function renderStrips(list, el, firstNote) {
|
||||||
|
list.forEach((s, si) => {
|
||||||
|
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
|
||||||
|
const pos = v => (v - min) / (max - min) * 100;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'strip-row';
|
||||||
|
let pts = '';
|
||||||
|
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
|
||||||
|
let lastBelow = -Infinity;
|
||||||
|
const nudge = (max - min) * 0.04;
|
||||||
|
sorted.forEach(({ v, i }) => {
|
||||||
|
const above = (v - lastBelow) < nudge;
|
||||||
|
if (!above) lastBelow = v;
|
||||||
|
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
|
||||||
|
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
|
||||||
|
});
|
||||||
|
const eng = s.eng != null
|
||||||
|
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">England ${s.eng}${unit}</span>`
|
||||||
|
: '';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="strip-head"><span class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</span>
|
||||||
|
${si === 0 && firstNote ? `<span class="eng-note">${firstNote}</span>` : ''}</div>
|
||||||
|
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
|
||||||
|
<div class="track"></div>
|
||||||
|
${eng}
|
||||||
|
${pts}
|
||||||
|
</div>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderStrips(strips, document.getElementById('strips'), '│ grey tick = England average');
|
||||||
|
renderStrips([
|
||||||
|
{ label: 'Grammar, punctuation & spelling — expected standard', eng: null, vals: [86, 94, 82] },
|
||||||
|
{ label: 'Science — expected standard (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.' },
|
||||||
|
{ label: 'Average scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
|
||||||
|
{ label: 'Average scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
|
||||||
|
{ label: 'Average scaled score — grammar, punctuation & spelling', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
|
||||||
|
], document.getElementById('strips-more'), null);
|
||||||
|
document.getElementById('strip-legend').innerHTML =
|
||||||
|
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
|
||||||
|
+ '<span class="li"><span class="engsw"></span>England average</span>';
|
||||||
|
|
||||||
|
/* ---- Sparklines (x honours real time: values are 15/16–18/19 then 22/23–24/25) ---- */
|
||||||
|
document.querySelectorAll('svg.spark').forEach(svg => {
|
||||||
|
const vals = svg.dataset.series.split(',').map(Number);
|
||||||
|
const color = css('--' + svg.dataset.color);
|
||||||
|
const w = +svg.getAttribute('width'), h = +svg.getAttribute('height');
|
||||||
|
const min = 40, max = 100;
|
||||||
|
const sslots = [0,1,2,3,7,8,9];
|
||||||
|
const x = i => 4 + sslots[i] * (w - 8) / 9;
|
||||||
|
const y = v => h - 6 - (v - min) * (h - 12) / (max - min);
|
||||||
|
const seg = idx => idx.map((i, k) => (k ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(vals[i]).toFixed(1)).join(' ');
|
||||||
|
svg.innerHTML = `<path d="${seg([0,1,2,3])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
|
||||||
|
+ `<path d="${seg([4,5,6])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
|
||||||
|
+ `<circle cx="${x(6)}" cy="${y(vals[6])}" r="3.5" fill="${color}"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Trend chart (metric-driven) ----
|
||||||
|
Tests were cancelled 2019/20-2020/21; lines always break across that band.
|
||||||
|
Nulls elsewhere are dataset gaps (no school-level 2021/22 rows; no
|
||||||
|
subject-level 2022/23 rows) and break the lines honestly. England figures
|
||||||
|
are official DfE (fact_ks2_national_averages); 2015/16 not loaded yet. */
|
||||||
|
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
|
||||||
|
const METRICS = {
|
||||||
|
rwm: {
|
||||||
|
label: 'Reading, writing & maths - expected standard',
|
||||||
|
school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
|
||||||
|
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1],
|
||||||
|
},
|
||||||
|
reading: {
|
||||||
|
label: 'Reading - expected standard',
|
||||||
|
school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
|
||||||
|
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0],
|
||||||
|
},
|
||||||
|
maths: {
|
||||||
|
label: 'Maths - expected standard',
|
||||||
|
school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
|
||||||
|
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
|
||||||
|
|
||||||
|
const svg = document.getElementById('trendchart');
|
||||||
|
const W = 880, H = 300, L = 44, R = 48, T = 18, B = 40;
|
||||||
|
// x positions honour real time: 2019/20 and 2020/21 (cancelled tests) sit between slots 3 and 6
|
||||||
|
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
|
||||||
|
const X = i => L + slots[i] * (W - L - R) / maxSlot;
|
||||||
|
const Y = v => T + (100 - v) * (H - T - B) / 60; // domain 40..100
|
||||||
|
|
||||||
|
// Contiguous non-null runs, additionally split across the covid band (between indices 3 and 4)
|
||||||
|
function runs(vals) {
|
||||||
|
const out = []; let cur = [];
|
||||||
|
vals.forEach((v, i) => {
|
||||||
|
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
|
||||||
|
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
|
||||||
|
cur.push(i);
|
||||||
|
});
|
||||||
|
if (cur.length) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(key) {
|
||||||
|
const m = METRICS[key];
|
||||||
|
const ink3 = css('--ink-3'), border = css('--border');
|
||||||
|
let g = '';
|
||||||
|
const bx1 = X(3) + 14, bx2 = X(4) - 14;
|
||||||
|
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
|
||||||
|
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 16}" text-anchor="middle">tests cancelled</text>`;
|
||||||
|
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 30}" text-anchor="middle">2019/20-2020/21</text>`;
|
||||||
|
for (let v = 40; v <= 100; v += 20) {
|
||||||
|
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
|
||||||
|
g += `<text x="${L - 8}" y="${Y(v) + 4}" text-anchor="end" font-size="11" fill="${ink3}">${v}%</text>`;
|
||||||
|
}
|
||||||
|
years.forEach((yr, i) => {
|
||||||
|
g += `<text x="${X(i)}" y="${H - B + 20}" text-anchor="middle" font-size="11" fill="${ink3}">${yr}</text>`;
|
||||||
|
});
|
||||||
|
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
|
||||||
|
runs(m.england).forEach(idx => {
|
||||||
|
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
|
||||||
|
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2.5" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
|
||||||
|
});
|
||||||
|
Object.entries(m.school).forEach(([name, vals]) => {
|
||||||
|
const color = schoolColor[name]();
|
||||||
|
runs(vals).forEach(idx => {
|
||||||
|
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||||
|
});
|
||||||
|
vals.forEach((v, i) => {
|
||||||
|
if (v == null) return;
|
||||||
|
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3.5" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// End labels, pushed apart when schools finish close together
|
||||||
|
const ends = Object.entries(m.school)
|
||||||
|
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
|
||||||
|
.filter(e => e.v != null)
|
||||||
|
.sort((a, b) => a.v - b.v);
|
||||||
|
let prevY = Infinity;
|
||||||
|
ends.forEach(e => {
|
||||||
|
let y = Y(e.v) + 4;
|
||||||
|
if (prevY - y < 13) y = prevY - 13;
|
||||||
|
prevY = y;
|
||||||
|
g += `<text x="${X(e.i) + 8}" y="${y}" font-size="11" font-weight="600" fill="${ink3}">${e.v}%</text>`;
|
||||||
|
});
|
||||||
|
svg.innerHTML = g;
|
||||||
|
svg.setAttribute('aria-label', `Line chart: ${m.label} by year for three schools and England average`);
|
||||||
|
document.getElementById('trend-legend').innerHTML =
|
||||||
|
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
|
||||||
|
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England average</span>`;
|
||||||
|
}
|
||||||
|
renderChart('rwm');
|
||||||
|
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
|
||||||
|
|
||||||
|
// Re-render on theme change so series colors pick up dark-mode tokens
|
||||||
|
const rerender = () => location.reload();
|
||||||
|
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) rerender(); })
|
||||||
|
.observe(document.documentElement, { attributes: true });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
<title>Compare screen — mobile mockup</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
|
||||||
|
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
|
||||||
|
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
|
||||||
|
--s1: #e07256; --s1-text: #b04a2e;
|
||||||
|
--s2: #00949b; --s2-text: #006a70;
|
||||||
|
--s3: #8664c9; --s3-text: #6a4bab;
|
||||||
|
--eng: #6d685f;
|
||||||
|
--good-bg: #e3efe6; --good-text: #1a6b34;
|
||||||
|
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
|
||||||
|
--neutral-bg: #efeae1; --neutral-text: #5c564d;
|
||||||
|
--shadow: 0 2px 8px rgba(26,22,18,0.06);
|
||||||
|
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||||
|
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
|
||||||
|
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
|
||||||
|
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
|
||||||
|
--s1: #f08b6e; --s1-text: #f4a58e;
|
||||||
|
--s2: #2fb8ae; --s2-text: #5fd0c8;
|
||||||
|
--s3: #a98fe0; --s3-text: #c0abec;
|
||||||
|
--eng: #9a927f;
|
||||||
|
--good-bg: #1e3325; --good-text: #7fd39a;
|
||||||
|
--warn-bg: #38301a; --warn-text: #e4c268;
|
||||||
|
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
|
||||||
|
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
|
||||||
|
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
|
||||||
|
--s1: #f08b6e; --s1-text: #f4a58e;
|
||||||
|
--s2: #2fb8ae; --s2-text: #5fd0c8;
|
||||||
|
--s3: #a98fe0; --s3-text: #c0abec;
|
||||||
|
--eng: #9a927f;
|
||||||
|
--good-bg: #1e3325; --good-text: #7fd39a;
|
||||||
|
--warn-bg: #38301a; --warn-text: #e4c268;
|
||||||
|
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
|
||||||
|
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
|
||||||
|
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
|
||||||
|
--s1: #e07256; --s1-text: #b04a2e;
|
||||||
|
--s2: #00949b; --s2-text: #006a70;
|
||||||
|
--s3: #8664c9; --s3-text: #6a4bab;
|
||||||
|
--eng: #6d685f;
|
||||||
|
--good-bg: #e3efe6; --good-text: #1a6b34;
|
||||||
|
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
|
||||||
|
--neutral-bg: #efeae1; --neutral-text: #5c564d;
|
||||||
|
--shadow: 0 2px 8px rgba(26,22,18,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--bg-2); color: var(--ink); font-family: var(--body); line-height: 1.45; }
|
||||||
|
|
||||||
|
/* Phone canvas: true mobile width, framed on larger screens */
|
||||||
|
.phone { max-width: 400px; margin: 0 auto; background: var(--bg); min-height: 100vh; }
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.phone { margin: 1.5rem auto; border: 1px solid var(--border); border-radius: 24px; overflow: hidden; box-shadow: var(--shadow); }
|
||||||
|
}
|
||||||
|
.inner { padding: 1rem 0.9rem 3rem; }
|
||||||
|
|
||||||
|
.mock-note {
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem; font-size: 0.78rem; color: var(--ink-2); margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.mock-note strong { color: var(--accent); }
|
||||||
|
|
||||||
|
h1 { font-family: var(--display); font-size: 1.7rem; font-weight: 700; margin: 0 0 0.2rem; }
|
||||||
|
.sub { color: var(--ink-2); margin: 0 0 1rem; font-size: 0.88rem; }
|
||||||
|
|
||||||
|
/* Sticky school chip bar — horizontal scroll */
|
||||||
|
.chipbar {
|
||||||
|
position: sticky; top: 0; z-index: 10; background: var(--bg);
|
||||||
|
display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border); margin: 0 -0.9rem 1rem; -webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.chipbar::-webkit-scrollbar { display: none; }
|
||||||
|
.schip {
|
||||||
|
flex: none; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.schip .dot { width: 9px; height: 9px; border-radius: 50%; }
|
||||||
|
.schip .x { border: none; background: var(--bg-2); color: var(--ink-3); border-radius: 50%; width: 17px; height: 17px; font-size: 0.7rem; line-height: 1; }
|
||||||
|
.schip.add { color: var(--accent); border-style: dashed; }
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-family: var(--display); font-size: 1.2rem; font-weight: 700;
|
||||||
|
margin: 1.6rem 0 0; padding-left: 0.6rem; border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.how { font-size: 0.78rem; color: var(--ink-3); margin: 0.3rem 0 0.75rem 0.8rem; }
|
||||||
|
|
||||||
|
/* Measure-first block: one measure, all schools under it */
|
||||||
|
.measure { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem 0.85rem; margin-bottom: 0.6rem; box-shadow: var(--shadow); }
|
||||||
|
.measure .mt { font-size: 0.85rem; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.measure .mh { font-size: 0.75rem; color: var(--ink-3); margin-top: 0.05rem; }
|
||||||
|
.srow { display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 0; border-top: 1px solid var(--border); margin-top: 0.45rem; flex-wrap: wrap; }
|
||||||
|
.srow:first-of-type { border-top: none; }
|
||||||
|
.srow .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
||||||
|
.srow .sn { font-size: 0.8rem; font-weight: 600; color: var(--ink-2); width: 4.6rem; flex: none; }
|
||||||
|
.srow .val { font-weight: 700; font-variant-numeric: tabular-nums; font-size: 0.95rem; }
|
||||||
|
.srow .note { font-size: 0.74rem; color: var(--ink-3); flex-basis: 100%; padding-left: 1.1rem; margin-top: -0.15rem; }
|
||||||
|
|
||||||
|
.chip { display: inline-block; font-size: 0.68rem; font-weight: 600; border-radius: 999px; padding: 0.12rem 0.5rem; white-space: nowrap; }
|
||||||
|
.chip.good { background: var(--good-bg); color: var(--good-text); }
|
||||||
|
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
|
||||||
|
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||||
|
.ofsted-badge { display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.14rem 0.5rem; font-size: 0.78rem; }
|
||||||
|
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
|
||||||
|
.illus { display: inline-block; font-size: 0.62rem; font-weight: 600; color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0 0.3rem; }
|
||||||
|
.help { display: inline-flex; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.6rem; align-items: center; justify-content: center; flex: none; }
|
||||||
|
|
||||||
|
/* Dot strips */
|
||||||
|
.strip-row { margin: 0.9rem 0 1.3rem; }
|
||||||
|
.strip-row .t { font-weight: 600; font-size: 0.82rem; }
|
||||||
|
.strip { position: relative; height: 32px; margin-top: 0.4rem; }
|
||||||
|
.strip .track { position: absolute; left: 0; right: 0; top: 14px; height: 4px; border-radius: 2px; background: var(--bg-2); }
|
||||||
|
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 24px; background: var(--eng); }
|
||||||
|
.strip .eng-lbl { position: absolute; top: -12px; transform: translateX(-50%); font-size: 0.62rem; color: var(--ink-3); white-space: nowrap; }
|
||||||
|
.strip .pt { position: absolute; top: 9px; width: 14px; height: 14px; border-radius: 50%; transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08); }
|
||||||
|
.strip .pt-lbl { position: absolute; top: 25px; transform: translateX(-50%); font-size: 0.64rem; font-weight: 600; }
|
||||||
|
.legend { display: flex; gap: 0.8rem; flex-wrap: wrap; font-size: 0.72rem; color: var(--ink-2); margin-top: 0.4rem; }
|
||||||
|
.legend .li { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.legend .sw { width: 9px; height: 9px; border-radius: 50%; }
|
||||||
|
.legend .engsw { width: 2px; height: 11px; background: var(--eng); }
|
||||||
|
|
||||||
|
.rc-list { display: flex; flex-direction: column; gap: 0.25rem; width: 100%; padding-left: 1.1rem; }
|
||||||
|
.rc-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.74rem; }
|
||||||
|
.rc-row .a { color: var(--ink-2); }
|
||||||
|
|
||||||
|
details.more-measures { margin-bottom: 0.6rem; }
|
||||||
|
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.82rem; color: var(--accent); padding: 0.2rem 0.2rem 0.5rem; }
|
||||||
|
.strip-note { font-size: 0.72rem; color: var(--ink-3); margin: 0.4rem 0.2rem 0; }
|
||||||
|
.metric-picker { margin-bottom: 0.6rem; }
|
||||||
|
.metric-picker select { width: 100%; font-family: inherit; font-size: 0.85rem; padding: 0.45rem 0.6rem; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--ink); }
|
||||||
|
.chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem; }
|
||||||
|
.swipe-hint { font-size: 0.7rem; color: var(--ink-3); text-align: center; margin-top: 0.3rem; }
|
||||||
|
.covid { fill: var(--bg-2); }
|
||||||
|
.covid-lbl { font-size: 0.6rem; fill: var(--ink-3); }
|
||||||
|
|
||||||
|
.footnote { font-size: 0.7rem; color: var(--ink-3); margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="phone"><div class="inner">
|
||||||
|
<p class="mock-note"><strong>Mobile mockup — proposed /compare.</strong> Mobile-first layout: measures stack vertically with all schools under each, so nothing needs horizontal swiping. Same live data as the desktop mockup.</p>
|
||||||
|
|
||||||
|
<h1>Compare schools</h1>
|
||||||
|
<p class="sub">Anchored against the England average — the grey tick — so you can tell what's typical at a glance.</p>
|
||||||
|
|
||||||
|
<div class="chipbar" aria-label="Schools in this comparison">
|
||||||
|
<span class="schip"><span class="dot" style="background:var(--s1)"></span>Barclay <button class="x" aria-label="Remove Barclay">×</button></span>
|
||||||
|
<span class="schip"><span class="dot" style="background:var(--s2)"></span>Elmhurst <button class="x" aria-label="Remove Elmhurst">×</button></span>
|
||||||
|
<span class="schip"><span class="dot" style="background:var(--s3)"></span>Plumcroft <button class="x" aria-label="Remove Plumcroft">×</button></span>
|
||||||
|
<span class="schip add">+ Add</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">At a glance</h2>
|
||||||
|
<p class="how">The short version — each measure is explained in its own section below.</p>
|
||||||
|
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Latest Ofsted inspection</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span><span class="chip good">4 areas Strong standard</span> <span class="chip neutral">2 areas Expected</span> <span class="chip warn">Attendance & behaviour: Attention needed</span> <span class="illus">illustrative</span></span><span class="note">New-style report card, Nov 2025 · safeguarding met · full detail in the Ofsted section below</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25).">?</span></div>
|
||||||
|
<div class="mh">England average: 62%</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val" style="color:var(--s1-text)">87%</span> <span class="chip good">Above average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val" style="color:var(--s2-text)">92%</span> <span class="chip good">Above average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val" style="color:var(--s3-text)">79%</span> <span class="chip good">Above average</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Getting a place</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="chip good">97% of first choices offered</span><span class="note">Named on 457 forms · 180 places</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="chip warn">73% of first choices offered</span><span class="note">Named on 342 forms · 120 places</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="chip good">All first choices offered</span><span class="note">Named on 185 forms · 80 places</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Ofsted inspection</h2>
|
||||||
|
<p class="how">Ofsted stopped giving a single overall grade in September 2024 (inspections until November 2025 kept the area-by-area judgements); from November 2025 new inspections produce a report card rating each area of school life (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older grade aren't directly comparable. Ofsted's "Expected standard" rating is unrelated to the KS2 test measure below.</p>
|
||||||
|
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Latest inspection</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="chip neutral">4+ years ago</span><span class="note">7 Oct 2021 · we don't hold area-by-area detail for this inspection · <a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Ofsted page →</a></span></div>
|
||||||
|
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s2);margin-top:4px"></span><span class="sn">Elmhurst</span><span><span class="ofsted-badge ofsted-1">Outstanding</span> <span class="chip neutral">4+ years ago</span> <span style="font-size:0.72rem;color:var(--ink-3)">6 Oct 2021</span></span>
|
||||||
|
<div class="rc-list">
|
||||||
|
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Behaviour & attitudes</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Leadership & management</span><span class="chip good">Outstanding</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Ofsted page →</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s3);margin-top:4px"></span><span class="sn">Plumcroft</span><span><strong style="font-size:0.85rem">Report card</strong> <span class="illus">illustrative</span> <span style="font-size:0.72rem;color:var(--ink-3)">14 Nov 2025</span></span>
|
||||||
|
<div class="rc-list">
|
||||||
|
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Curriculum & teaching</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Attendance & behaviour</span><span class="chip warn">Attention needed</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Leadership & governance</span><span class="chip good">Strong standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
|
||||||
|
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Ofsted page →</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">How children do academically</h2>
|
||||||
|
<p class="how">End of Year 6 national tests and teacher assessments (2024/25) — writing is teacher-assessed. Each line runs 0–100%; the grey tick is the England average.</p>
|
||||||
|
<div class="measure" id="strips"></div>
|
||||||
|
<details class="more-measures">
|
||||||
|
<summary>More measures — grammar, punctuation & spelling, science, scaled scores</summary>
|
||||||
|
<div class="measure" id="strips-more" style="margin-top:0.5rem"></div>
|
||||||
|
<p class="strip-note">Strips show the 100–120 window of the full 80–120 scaled-score range; 100 is the expected standard (the strip widens if a school averages below it). England ticks for GPS and science aren't in our dataset yet, and the scaled-score ticks are indicative — official DfE figures will be loaded before launch.</p>
|
||||||
|
</details>
|
||||||
|
<div class="legend" id="strip-legend" style="padding:0 0.2rem 0"></div>
|
||||||
|
|
||||||
|
<div class="measure" style="margin-top:0.9rem">
|
||||||
|
<div class="mt">Children from lower-income families <span class="help" title="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Small pupil groups — single pupils can move a school's figure noticeably.">?</span></div>
|
||||||
|
<div class="mh">State-school average: 46%</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">86%</span> <span class="chip good">Well above average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">93%</span> <span class="chip good">Well above average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">72%</span> <span class="chip good">Above average</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Getting a place</h2>
|
||||||
|
<p class="how">September 2026 entry. "First choice" = families who ranked the school top of their form (officially a "first preference"). Schools never see your ranking — places go by the admission criteria alone. Figures are National Offer Day offers; waiting lists and appeals can change the final intake.</p>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">First-choice families offered a place</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">97%</span><span class="note">Named on 457 forms · 180 places</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">73%</span> <span class="chip warn">Over 1 in 4 missed out</span><span class="note">Named on 342 forms · 120 places — check the school's admission criteria (for most non-faith primaries, distance decides)</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">100%</span><span class="note">Named on 185 forms · 80 places · every first choice offered</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Who goes there</h2>
|
||||||
|
<p class="how">From the latest school census (2025/26). No "right" numbers here — just context.</p>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Pupils on roll</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">1,273</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">980</span><span class="note">98% full · much larger than average · girls 48% / boys 52%</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">1,056</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Free school meals <span class="help" title="% eligible for free school meals. State-school average: 25% (computed from our dataset).">?</span></div>
|
||||||
|
<div class="mh">State-school average: 25% (our dataset)</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">26%</span> <span class="chip neutral">About average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">25%</span> <span class="chip neutral">About average</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">30%</span> <span class="chip neutral">A little above</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">English as an additional language · extra learning support (SEN) <span class="help" title="SEN = pupils receiving SEN support, not including EHC plans. State-school average: ≈14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">EAL 62% · SEN 6%</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">EAL 84% · SEN 8%</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">EAL 20% · SEN 28% <span class="chip neutral">SEN well above avg</span></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="mt">Basics</div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">Ages 3–11 · nursery · no faith · Lion Academy Trust</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">Ages 3–11 · nursery · no faith · New Vision Trust</span></div>
|
||||||
|
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">Ages 3–11 · nursery · no faith · Greenwich council</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Explore trends</h2>
|
||||||
|
<p class="how">Every measure from the current compare page lives on here, grouped. Three are wired up in this mockup. School lines break where a year isn't in our dataset.</p>
|
||||||
|
<div class="metric-picker">
|
||||||
|
<select id="metric-select" aria-label="Measure">
|
||||||
|
<optgroup label="Expected standard">
|
||||||
|
<option value="rwm" selected>Reading, writing & maths (combined)</option>
|
||||||
|
<option value="reading">Reading</option>
|
||||||
|
<option value="maths">Maths</option>
|
||||||
|
<option disabled>Writing (TA) · GPS · Science (TA)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Higher standard"><option disabled>RWM · Reading · Maths · GPS</option><option disabled>Writing — greater depth (TA)</option></optgroup>
|
||||||
|
<optgroup label="Average scaled scores"><option disabled>Reading · Maths · GPS</option></optgroup>
|
||||||
|
<optgroup label="Equity"><option disabled>Disadvantaged pupils — expected standard</option></optgroup>
|
||||||
|
<optgroup label="School context"><option disabled>FSM % · EAL % · SEN support %</option></optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<svg id="trendchart" width="620" height="240" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
|
||||||
|
<div class="legend" id="trend-legend"></div>
|
||||||
|
</div>
|
||||||
|
<p class="swipe-hint">← swipe the chart →</p>
|
||||||
|
|
||||||
|
<p class="footnote">
|
||||||
|
Sources: DfE Compare School Performance, Ofsted inspection outcomes, DfE admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are official DfE figures; FSM, language, SEN, size and disadvantaged-pupil benchmarks are computed across state schools in our dataset. Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023). Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". Static mockup — tooltips and "+ Add" are illustrative.
|
||||||
|
</p>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||||
|
const schools = [
|
||||||
|
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
|
||||||
|
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
|
||||||
|
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const strips = [
|
||||||
|
{ label: 'Reading, writing & maths', eng: 62, vals: [87, 92, 79] },
|
||||||
|
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
|
||||||
|
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
|
||||||
|
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
|
||||||
|
{ label: 'Higher standard', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
|
||||||
|
];
|
||||||
|
function renderStrips(list, el) {
|
||||||
|
list.forEach((s) => {
|
||||||
|
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
|
||||||
|
const pos = v => (v - min) / (max - min) * 100;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'strip-row';
|
||||||
|
let pts = '';
|
||||||
|
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
|
||||||
|
let lastBelow = -Infinity;
|
||||||
|
const nudge = (max - min) * 0.08;
|
||||||
|
sorted.forEach(({ v, i }) => {
|
||||||
|
const above = (v - lastBelow) < nudge;
|
||||||
|
if (!above) lastBelow = v;
|
||||||
|
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
|
||||||
|
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
|
||||||
|
});
|
||||||
|
const eng = s.eng != null
|
||||||
|
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">Eng ${s.eng}${unit}</span>`
|
||||||
|
: '';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</div>
|
||||||
|
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
|
||||||
|
<div class="track"></div>
|
||||||
|
${eng}
|
||||||
|
${pts}
|
||||||
|
</div>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderStrips(strips, document.getElementById('strips'));
|
||||||
|
renderStrips([
|
||||||
|
{ label: 'Grammar, punctuation & spelling', eng: null, vals: [86, 94, 82] },
|
||||||
|
{ label: 'Science (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — no KS2 science test since 2009; comparisons are indicative.' },
|
||||||
|
{ label: 'Avg scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
|
||||||
|
{ label: 'Avg scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
|
||||||
|
{ label: 'Avg scaled score — GPS', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
|
||||||
|
], document.getElementById('strips-more'));
|
||||||
|
document.getElementById('strip-legend').innerHTML =
|
||||||
|
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
|
||||||
|
+ '<span class="li"><span class="engsw"></span>England average</span>';
|
||||||
|
|
||||||
|
/* Metric-driven trend chart. Lines always break across the covid band
|
||||||
|
(2019/20-2020/21, cancelled tests); other nulls are dataset gaps. */
|
||||||
|
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
|
||||||
|
const METRICS = {
|
||||||
|
rwm: { school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
|
||||||
|
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1] },
|
||||||
|
reading: { school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
|
||||||
|
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0] },
|
||||||
|
maths: { school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
|
||||||
|
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0] },
|
||||||
|
};
|
||||||
|
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
|
||||||
|
const svg = document.getElementById('trendchart');
|
||||||
|
const W = 620, H = 240, L = 38, R = 44, T = 14, B = 34;
|
||||||
|
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
|
||||||
|
const X = i => L + slots[i] * (W - L - R) / maxSlot;
|
||||||
|
const Y = v => T + (100 - v) * (H - T - B) / 60;
|
||||||
|
function runs(vals) {
|
||||||
|
const out = []; let cur = [];
|
||||||
|
vals.forEach((v, i) => {
|
||||||
|
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
|
||||||
|
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
|
||||||
|
cur.push(i);
|
||||||
|
});
|
||||||
|
if (cur.length) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function renderChart(key) {
|
||||||
|
const m = METRICS[key];
|
||||||
|
const ink3 = css('--ink-3'), border = css('--border');
|
||||||
|
let g = '';
|
||||||
|
const bx1 = X(3) + 12, bx2 = X(4) - 12;
|
||||||
|
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
|
||||||
|
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 14}" text-anchor="middle">tests cancelled</text>`;
|
||||||
|
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 26}" text-anchor="middle">'19/20-'20/21</text>`;
|
||||||
|
for (let v = 40; v <= 100; v += 20) {
|
||||||
|
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
|
||||||
|
g += `<text x="${L - 6}" y="${Y(v) + 4}" text-anchor="end" font-size="10" fill="${ink3}">${v}%</text>`;
|
||||||
|
}
|
||||||
|
years.forEach((yr, i) => {
|
||||||
|
g += `<text x="${X(i)}" y="${H - B + 18}" text-anchor="middle" font-size="9.5" fill="${ink3}">${yr}</text>`;
|
||||||
|
});
|
||||||
|
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
|
||||||
|
runs(m.england).forEach(idx => {
|
||||||
|
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
|
||||||
|
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
|
||||||
|
});
|
||||||
|
Object.entries(m.school).forEach(([name, vals]) => {
|
||||||
|
const color = schoolColor[name]();
|
||||||
|
runs(vals).forEach(idx => {
|
||||||
|
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||||
|
});
|
||||||
|
vals.forEach((v, i) => {
|
||||||
|
if (v == null) return;
|
||||||
|
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const ends = Object.entries(m.school)
|
||||||
|
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
|
||||||
|
.filter(e => e.v != null)
|
||||||
|
.sort((a, b) => a.v - b.v);
|
||||||
|
let prevY = Infinity;
|
||||||
|
ends.forEach(e => {
|
||||||
|
let y = Y(e.v) + 3.5;
|
||||||
|
if (prevY - y < 12) y = prevY - 12;
|
||||||
|
prevY = y;
|
||||||
|
g += `<text x="${X(e.i) + 7}" y="${y}" font-size="10" font-weight="600" fill="${ink3}">${e.v}%</text>`;
|
||||||
|
});
|
||||||
|
svg.innerHTML = g;
|
||||||
|
document.getElementById('trend-legend').innerHTML =
|
||||||
|
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
|
||||||
|
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England avg</span>`;
|
||||||
|
}
|
||||||
|
renderChart('rwm');
|
||||||
|
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
|
||||||
|
|
||||||
|
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) location.reload(); })
|
||||||
|
.observe(document.documentElement, { attributes: true });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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,361 @@
|
|||||||
|
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/"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two URNs guaranteed to be pure-primary (same phase). The compare page's
|
||||||
|
* phase tabs split all-through schools (which carry KS4 data) onto the
|
||||||
|
* secondary tab, so picking two arbitrary "primary" search hits can land
|
||||||
|
* them on different tabs where only the active one renders. Selecting via
|
||||||
|
* the API by exact phase keeps both on the same tab. Data-invariant: uses
|
||||||
|
* whatever primaries the environment holds.
|
||||||
|
*/
|
||||||
|
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=primary&per_page=50');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; rwm_expected_pct?: number | null }) =>
|
||||||
|
s.phase === 'Primary' && s.rwm_expected_pct != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||||
|
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 with no performance data still gets a working detail page', async ({ page }) => {
|
||||||
|
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||||
|
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||||
|
// serialization — which the frontend rendered as a 404 on every such SEO
|
||||||
|
// landing page. Find one via the search API (year === null marks "no
|
||||||
|
// performance rows") and assert its page renders.
|
||||||
|
const candidates: number[] = [];
|
||||||
|
for (const q of ['post 16', 'specialist college', 'sixth form']) {
|
||||||
|
const resp = await page.request.get(
|
||||||
|
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
|
||||||
|
);
|
||||||
|
if (!resp.ok()) continue;
|
||||||
|
const body = await resp.json();
|
||||||
|
for (const s of body.schools ?? []) {
|
||||||
|
if (s.year === null && s.urn) candidates.push(s.urn);
|
||||||
|
}
|
||||||
|
if (candidates.length) break;
|
||||||
|
}
|
||||||
|
test.skip(candidates.length === 0, 'no results-less school in this dataset');
|
||||||
|
|
||||||
|
const detail = await page.request.get(`/api/schools/${candidates[0]}`);
|
||||||
|
expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200);
|
||||||
|
|
||||||
|
await page.goto(`/school/${candidates[0]}`);
|
||||||
|
await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
|
||||||
|
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
|
||||||
|
// CSS overlay. Simulate that by removing the API before any page script runs.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// @ts-expect-error deliberate API removal
|
||||||
|
delete Element.prototype.requestFullscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
await searchByName(page, 'primary');
|
||||||
|
const firstSchool = schoolLinks(page).first();
|
||||||
|
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
|
||||||
|
await firstSchool.click();
|
||||||
|
await page.waitForURL(/\/school\//);
|
||||||
|
|
||||||
|
const openMap = page.getByRole('button', { name: 'Open full map' });
|
||||||
|
await expect(openMap).toBeVisible({ timeout: 15_000 });
|
||||||
|
await openMap.click();
|
||||||
|
|
||||||
|
const closeMap = page.getByRole('button', { name: 'Close map' });
|
||||||
|
await expect(closeMap).toBeVisible();
|
||||||
|
await closeMap.click();
|
||||||
|
await expect(openMap).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
||||||
|
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
||||||
|
// map's fullscreen button must fall back to a CSS overlay.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// @ts-expect-error deliberate API removal
|
||||||
|
delete Element.prototype.requestFullscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Switch to the map view, then open the map fullscreen.
|
||||||
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||||
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||||
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||||
|
await openFs.click();
|
||||||
|
|
||||||
|
// The button flips to its exit state once the overlay is up.
|
||||||
|
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
||||||
|
await expect(exitFs).toBeVisible();
|
||||||
|
await exitFs.click();
|
||||||
|
await expect(openFs).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
||||||
|
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||||
|
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
|
// Both schools' detail links should render in the comparison view
|
||||||
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The parent-first sections render in order (data-invariant: headings only)
|
||||||
|
for (const heading of [
|
||||||
|
'At a glance',
|
||||||
|
'Ofsted inspection',
|
||||||
|
/How (children|students) do academically/,
|
||||||
|
'Who goes there',
|
||||||
|
'Explore trends',
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: heading }).first(),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every number gets an anchor: at least one England-average tick or label
|
||||||
|
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Desktop: the sticky school bar shares the sections' grid template
|
||||||
|
// (200px label rail + one column per school) so chips align with the
|
||||||
|
// columns they label.
|
||||||
|
const barTemplate = await page
|
||||||
|
.locator('[aria-label="Schools in this comparison"]')
|
||||||
|
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
|
||||||
|
expect(barTemplate).toMatch(/^200px /);
|
||||||
|
// ...and its label rail carries the comparison caption.
|
||||||
|
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
|
||||||
|
|
||||||
|
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
||||||
|
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
||||||
|
await expect(ofstedLink).toBeVisible();
|
||||||
|
expect(await ofstedLink.getAttribute('href')).toMatch(
|
||||||
|
/reports\.ofsted\.gov\.uk\/provider\/21\/\d+/
|
||||||
|
);
|
||||||
|
|
||||||
|
// A school never shows both an overall-grade badge AND report-card detail:
|
||||||
|
// "Report card" implies "no overall grade is given" copy is present too.
|
||||||
|
const reportCards = await page.getByText('Report card', { exact: true }).count();
|
||||||
|
if (reportCards > 0) {
|
||||||
|
await expect(page.getByText(/no overall grade/i).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||||
|
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The parent-first sections must render — this page was completely blank
|
||||||
|
// for all-secondary baskets (expert review must-fix #1).
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible();
|
||||||
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The admissions template must be phase-aware: the primaries' distance
|
||||||
|
// copy ("non-faith primaries") must never appear on a secondary comparison
|
||||||
|
// (expert sign-off must-fix M3).
|
||||||
|
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
||||||
|
// Regression: the first visit stores a basket in localStorage; opening a
|
||||||
|
// link for a DIFFERENT school set then raced a stale fetch for the stored
|
||||||
|
// basket against the new SSR data, blanking every section (including the
|
||||||
|
// trends chart) until a hard refresh.
|
||||||
|
const [s0, s1] = await twoSecondaryUrns(page);
|
||||||
|
const [p0, p1] = await twoPrimaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${s0},${s1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${p0},${p1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
// Give any straggling stale response time to land, then confirm the new
|
||||||
|
// comparison is still on screen.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
|
||||||
|
await expect(page.locator(`a[href*="${p0}"]`).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]}`);
|
||||||
|
|
||||||
|
// Mobile is measure-first: the At a glance section stacks all active-phase
|
||||||
|
// schools inside one flow — no horizontal swiping between school columns.
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'At a glance' }),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
const body = page.locator('body');
|
||||||
|
const bodyOverflowsX = await body.evaluate(
|
||||||
|
(el) => el.scrollWidth > el.clientWidth + 1,
|
||||||
|
);
|
||||||
|
expect(bodyOverflowsX).toBe(false);
|
||||||
|
|
||||||
|
// The sticky school bar must pin *below* the sticky site header, not at
|
||||||
|
// top:0 where the header covers it and the selected schools are hidden.
|
||||||
|
// Assert the sticky offset directly (robust — no scroll timing needed).
|
||||||
|
const barTop = await page
|
||||||
|
.locator('[class*="schoolBar"]')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => parseFloat(getComputedStyle(el).top));
|
||||||
|
const headerHeight = await page
|
||||||
|
.locator('[class*="header"]')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => el.getBoundingClientRect().height);
|
||||||
|
expect(barTop).toBeGreaterThanOrEqual(headerHeight - 1);
|
||||||
|
|
||||||
|
// The trends chart still renders (inside the Explore trends section)…
|
||||||
|
const chartCanvas = page.locator('canvas:visible').first();
|
||||||
|
await expect(chartCanvas).toBeVisible({ timeout: 15_000 });
|
||||||
|
// …at a real height, not the squashed ~150px Chart.js fallback that
|
||||||
|
// appears when the container lacks a definite height.
|
||||||
|
const chartBox = await chartCanvas.boundingBox();
|
||||||
|
expect(chartBox && chartBox.height).toBeGreaterThan(220);
|
||||||
|
|
||||||
|
// …with the mobile chart legend chips and tap-to-focus behaviour intact.
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Getting a place — phase and school-type correctness (expert sign-off
|
||||||
|
* must-fixes M1/M3):
|
||||||
|
* - an all-through school's Year 7 round must never render on the primary
|
||||||
|
* tab as if it were Reception odds;
|
||||||
|
* - selective schools get entrance-test framing, and the secondary tab
|
||||||
|
* never shows the primaries' distance template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
|
||||||
|
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string, extra: Partial<School> = {}): School {
|
||||||
|
return { urn, school_name: name, ...extra } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||||
|
return {
|
||||||
|
year: 202627,
|
||||||
|
school_phase: 'Secondary',
|
||||||
|
places_offered: 173,
|
||||||
|
total_applications: 433,
|
||||||
|
first_preference_offer_pct: 83,
|
||||||
|
oversubscribed: true,
|
||||||
|
...partial,
|
||||||
|
} as SchoolAdmissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: info,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: a,
|
||||||
|
admissions_history: a ? [a] : [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CompareAdmissions', () => {
|
||||||
|
it("does not show an all-through school's Year 7 round on the primary tab", () => {
|
||||||
|
// The real M1 scenario: an all-through school (Year 7 round only) beside
|
||||||
|
// a primary with a Reception round.
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const primary = school(138690, 'Barclay Primary School');
|
||||||
|
const data = {
|
||||||
|
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
|
||||||
|
'138690': entry(
|
||||||
|
primary,
|
||||||
|
admissions({
|
||||||
|
school_phase: 'Primary',
|
||||||
|
total_applications: 300,
|
||||||
|
places_offered: 120,
|
||||||
|
first_preference_offer_pct: 96,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
// Hessle's Year 7 figures must not appear…
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/We don't hold Reception admissions data for this school/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// …while Barclay's Reception round renders normally.
|
||||||
|
expect(screen.getByText('300')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase-labels the section empty state when no matching round exists at all', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No Reception admissions data is available for these schools yet/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Year 7 round on the secondary tab', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('433')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('173')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives selective schools entrance-test framing, never the distance template', () => {
|
||||||
|
const grammar = school(136276, 'Watford Grammar School for Boys', {
|
||||||
|
admissions_policy: 'Selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
|
||||||
|
const faithSchool = school(102052, "Bishop Stopford's School", {
|
||||||
|
admissions_policy: 'Non-selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
|
||||||
|
const primary = school(100140, 'Plumcroft Primary School');
|
||||||
|
const data = {
|
||||||
|
'100140': entry(
|
||||||
|
primary,
|
||||||
|
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareOfsted } from '@/components/compare/CompareOfsted';
|
||||||
|
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return { urn, school_name: name } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||||
|
return {
|
||||||
|
framework: null,
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
inspection_type: null,
|
||||||
|
overall_effectiveness: null,
|
||||||
|
quality_of_education: null,
|
||||||
|
behaviour_attitudes: null,
|
||||||
|
personal_development: null,
|
||||||
|
leadership_management: null,
|
||||||
|
early_years_provision: null,
|
||||||
|
previous_overall: null,
|
||||||
|
rc_safeguarding_met: null,
|
||||||
|
rc_inclusion: null,
|
||||||
|
rc_curriculum_teaching: null,
|
||||||
|
rc_achievement: null,
|
||||||
|
rc_attendance_behaviour: null,
|
||||||
|
rc_personal_development: null,
|
||||||
|
rc_leadership_governance: null,
|
||||||
|
rc_early_years: null,
|
||||||
|
rc_sixth_form: null,
|
||||||
|
ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1',
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
|
||||||
|
|
||||||
|
const data: Record<string, ComparisonData> = {
|
||||||
|
'1': {
|
||||||
|
school_info: schools[0],
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
school_info: schools[1],
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
||||||
|
},
|
||||||
|
'3': {
|
||||||
|
school_info: schools[2],
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
inspection_date: '2025-11-14',
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
report_card: {
|
||||||
|
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||||
|
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CompareOfsted', () => {
|
||||||
|
it('renders the three regimes without inventing an overall grade for report cards', () => {
|
||||||
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Outstanding')).toBeInTheDocument();
|
||||||
|
// Carried-forward grade is shown but marked as such
|
||||||
|
expect(screen.getByText('Good')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
|
||||||
|
// Report card: label present, no overall-grade badge for that school
|
||||||
|
expect(screen.getByText('Report card')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses one chip-list grammar for both regimes in judgement detail', () => {
|
||||||
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
// report-card area chip
|
||||||
|
expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Needs attention')).toBeInTheDocument();
|
||||||
|
// graded school without published subgrades → honest dataset statement
|
||||||
|
expect(
|
||||||
|
screen.getAllByText(/We don't hold area-by-area detail/i).length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the mixed-regime comparability note only when regimes differ', () => {
|
||||||
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links every school to its Ofsted page', () => {
|
||||||
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
const links = screen.getAllByRole('link', { name: /Ofsted page/i });
|
||||||
|
expect(links).toHaveLength(3);
|
||||||
|
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
|
||||||
|
const sentinelSchool = school(6, 'Sentinel School');
|
||||||
|
const sentinelData: Record<string, ComparisonData> = {
|
||||||
|
'6': {
|
||||||
|
school_info: sentinelSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
overall_effectiveness: 2,
|
||||||
|
grade_source: 'graded',
|
||||||
|
quality_of_education: 1,
|
||||||
|
early_years_provision: 9,
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
|
||||||
|
// Real grades render…
|
||||||
|
expect(screen.getByText('Quality of education')).toBeInTheDocument();
|
||||||
|
// …the applicable sixth-form judgement renders (was previously dropped)…
|
||||||
|
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
|
||||||
|
// …and the not-applicable sentinel never appears, neither as area nor code.
|
||||||
|
expect(screen.queryByText('Early years provision')).toBeNull();
|
||||||
|
expect(screen.queryByText('9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
|
const cardSchool = school(4, 'Dated Card School');
|
||||||
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
'4': {
|
||||||
|
school_info: cardSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: '2026-02-03',
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||||
|
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||||
|
const cardSchool = school(5, 'Undated Card School');
|
||||||
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
'5': {
|
||||||
|
school_info: cardSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: null,
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a per-measure mobile tag with the short school name', () => {
|
||||||
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
// Each measure repeats the schools, so the short name ("Graded" from
|
||||||
|
// "Graded School") appears once per measure (4) via the cell tag.
|
||||||
|
expect(screen.getAllByText('Graded').length).toBe(4);
|
||||||
|
expect(screen.getAllByText('Card').length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Regression: an all-secondary comparison must render the secondary sections.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||||
|
* effect must re-run once selectedSchools arrives — with deps of only
|
||||||
|
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||||
|
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||||
|
* comparison") even though all schools were secondary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function secondarySchool(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy converter',
|
||||||
|
attainment_8_score: 55,
|
||||||
|
phase: 'Secondary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: secondarySchool(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'300': data(300, 'Gamma High'),
|
||||||
|
'400': data(400, 'Delta Academy'),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: { attainment_8_score: 46 },
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[300, 400]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="attainment_8_score"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||||
|
// The sticky bar's rail caption reflects the active phase and count.
|
||||||
|
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Regression: on refresh, the compare page must show the SSR-rendered data.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount (selectedSchools is
|
||||||
|
* empty for the first render), so the fetch effect must not blank the
|
||||||
|
* SSR payload during that window — and must not refetch data the server
|
||||||
|
* already provided.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Community school',
|
||||||
|
rwm_expected_pct: 80,
|
||||||
|
phase: 'Primary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'100': data(100, 'Alpha Primary'),
|
||||||
|
'200': data(200, 'Beta Primary'),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchComparison.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders SSR data on refresh without wiping it or refetching', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: { rwm_expected_pct: 62 },
|
||||||
|
secondary: {},
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[100, 200]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="rwm_expected_pct"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both SSR-provided schools appear (data was not blanked during hydration)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// …and the client never refetched data the server already rendered.
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Regression: opening a compare link while a DIFFERENT basket is stored must
|
||||||
|
* not blank the page.
|
||||||
|
*
|
||||||
|
* The basket hydrates from localStorage first, which can fire a fetch for the
|
||||||
|
* OLD school set; the URL-seed effect then replaces the basket with the URL's
|
||||||
|
* schools (already covered by SSR data, so no new fetch). When the stale
|
||||||
|
* response for the old set finally lands, it must not clobber the fresh SSR
|
||||||
|
* data — that left every section (including the trends chart) empty until a
|
||||||
|
* hard refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Community school',
|
||||||
|
rwm_expected_pct: 80,
|
||||||
|
phase: 'Primary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visitor's previously stored basket (a different school entirely).
|
||||||
|
const STORED_SCHOOL = school(900, 'Old Stored School');
|
||||||
|
|
||||||
|
// The comparison the URL (and SSR) actually asked for.
|
||||||
|
const URL_DATA = {
|
||||||
|
'100': data(100, 'Alpha Primary'),
|
||||||
|
'200': data(200, 'Beta Primary'),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchComparison.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
|
||||||
|
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
|
||||||
|
|
||||||
|
const pending: Array<(v: unknown) => void> = [];
|
||||||
|
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={URL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: { rwm_expected_pct: 62 },
|
||||||
|
secondary: {},
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[100, 200]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="rwm_expected_pct"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The URL's schools render from SSR data once the basket is reseeded.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The transient stored-basket fetch (for school 900) resolves LATE, after
|
||||||
|
// the basket has moved on to the URL's schools.
|
||||||
|
await act(async () => {
|
||||||
|
for (const resolve of pending) {
|
||||||
|
resolve({
|
||||||
|
comparison: { '900': data(900, 'Old Stored School') },
|
||||||
|
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
|
||||||
|
benchmarks: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page must still show the URL comparison — not go blank.
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { DotStrip } from '@/components/DotStrip';
|
||||||
|
|
||||||
|
describe('DotStrip', () => {
|
||||||
|
it('enumerates anchor and school values in the aria-label', () => {
|
||||||
|
render(
|
||||||
|
<DotStrip
|
||||||
|
label="Reading"
|
||||||
|
values={[91, 92, 87]}
|
||||||
|
schoolNames={['Barclay', 'Elmhurst', 'Plumcroft']}
|
||||||
|
anchor={{ value: 75, label: 'England 75%' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const strip = screen.getByRole('img');
|
||||||
|
expect(strip).toHaveAccessibleName(
|
||||||
|
'Reading: England 75%, Barclay 91%, Elmhurst 92%, Plumcroft 87%',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the anchor tick when provided and not otherwise', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<DotStrip
|
||||||
|
label="Reading"
|
||||||
|
values={[91]}
|
||||||
|
schoolNames={['Barclay']}
|
||||||
|
anchor={{ value: 75, label: 'England 75%' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('England 75%')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DotStrip label="Science" values={[95]} schoolNames={['Barclay']} anchor={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/England/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips schools without a value', () => {
|
||||||
|
render(
|
||||||
|
<DotStrip
|
||||||
|
label="Maths"
|
||||||
|
values={[91, null]}
|
||||||
|
schoolNames={['Barclay', 'Elmhurst']}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('img')).toHaveAccessibleName('Maths: Barclay 91%');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* SecondarySchoolRow — sixth-form tag must come from the GIAS
|
||||||
|
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
|
||||||
|
import type { School } from '@/lib/types';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
urn: 100002,
|
||||||
|
school_name: 'Beta Sixth Form College',
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy',
|
||||||
|
phase: 'Secondary',
|
||||||
|
gender: 'Mixed',
|
||||||
|
attainment_8_score: 50.0,
|
||||||
|
} as unknown as School;
|
||||||
|
|
||||||
|
describe('SecondarySchoolRow sixth-form tag', () => {
|
||||||
|
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Sixth form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag for an 11-18 school without a registered sixth form', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SecondarySchoolRow proposed-to-close tag', () => {
|
||||||
|
it('shows the tag when GIAS status is "Open, but proposed to close"', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, status: 'Open, but proposed to close' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Proposed to close/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag for a plain open school', () => {
|
||||||
|
render(<SecondarySchoolRow school={{ ...base, status: 'Open' }} />);
|
||||||
|
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tag when status is missing', () => {
|
||||||
|
render(<SecondarySchoolRow school={base} />);
|
||||||
|
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* buildCompareChart: every selected school must produce a rendered series
|
||||||
|
* (regression guard for the production bug where a third school's line
|
||||||
|
* vanished), the x-axis must include cancelled/unpublished years as real
|
||||||
|
* gaps (never compressing time), and the England overlay renders dashed
|
||||||
|
* with no gap-bridging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { buildCompareChart, fillAcademicYears } from '@/lib/compareChartData';
|
||||||
|
import type { ComparisonData } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, years: Array<[number, number | null]>): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: { urn, school_name: `School ${urn}` } as ComparisonData['school_info'],
|
||||||
|
yearly_data: years.map(([year, v]) => ({ year, rwm_expected_pct: v })) as ComparisonData['yearly_data'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const THREE_SCHOOLS = {
|
||||||
|
'1': school(1, [[201819, 87], [202223, 87], [202425, 87]]),
|
||||||
|
'2': school(2, [[201819, 88], [202223, 88], [202425, 92]]),
|
||||||
|
'3': school(3, [[201819, 69], [202223, 62], [202425, 79]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHOOL_LIST = [1, 2, 3].map((urn) => ({ urn, school_name: `School ${urn}` }));
|
||||||
|
|
||||||
|
describe('fillAcademicYears', () => {
|
||||||
|
it('fills every academic year between min and max', () => {
|
||||||
|
expect(fillAcademicYears([201819, 202223])).toEqual([
|
||||||
|
201819, 201920, 202021, 202122, 202223,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCompareChart', () => {
|
||||||
|
it('renders one series per selected school — none silently dropped', () => {
|
||||||
|
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||||
|
expect(chart.schoolDatasets).toHaveLength(3);
|
||||||
|
for (const ds of chart.schoolDatasets) {
|
||||||
|
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles float years from the API (202425.0 style)', () => {
|
||||||
|
const floaty = {
|
||||||
|
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||||
|
};
|
||||||
|
const chart = buildCompareChart(floaty, [SCHOOL_LIST[0]], 'rwm_expected_pct');
|
||||||
|
expect(chart.schoolDatasets[0].data.filter((v) => v != null)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes cancelled/unpublished years as null gaps, not compressed time', () => {
|
||||||
|
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||||
|
expect(chart.years).toContain(201920);
|
||||||
|
expect(chart.years).toContain(202122);
|
||||||
|
const idx = chart.years.indexOf(202021);
|
||||||
|
expect(chart.schoolDatasets[0].data[idx]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a dashed England overlay when national data is supplied', () => {
|
||||||
|
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
|
201819: 64.9,
|
||||||
|
202122: 58.7,
|
||||||
|
202223: 59.5,
|
||||||
|
202425: 62.1,
|
||||||
|
});
|
||||||
|
expect(chart.englandDataset).not.toBeNull();
|
||||||
|
const eng = chart.englandDataset!;
|
||||||
|
expect(eng.label).toBe('England average');
|
||||||
|
expect(eng.borderDash).toEqual([5, 4]);
|
||||||
|
expect(eng.spanGaps).toBe(false);
|
||||||
|
// England has a value for 2021/22 even though schools do not
|
||||||
|
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
||||||
|
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
|
202122: 58.7,
|
||||||
|
});
|
||||||
|
expect(withNational.showUnpublished202122Note).toBe(true);
|
||||||
|
const withoutNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||||
|
expect(withoutNational.showUnpublished202122Note).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* compareLogic encodes the expert-reviewed comprehension rules for the
|
||||||
|
* compare screen: report-card summarisation (safeguarding never counted),
|
||||||
|
* three-regime Ofsted display, one consistent admissions chip metric,
|
||||||
|
* CI-based progress banding, verdict chips and dot-strip geometry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
OFSTED_LEGACY_GRADES,
|
||||||
|
admissionsForPhase,
|
||||||
|
ofstedDisplay,
|
||||||
|
progressBand,
|
||||||
|
rcAreaLabel,
|
||||||
|
stripPositions,
|
||||||
|
summariseAdmissions,
|
||||||
|
summariseReportCard,
|
||||||
|
verdict,
|
||||||
|
} from '@/lib/compareLogic';
|
||||||
|
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
|
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||||
|
return {
|
||||||
|
framework: null,
|
||||||
|
inspection_date: null,
|
||||||
|
inspection_type: null,
|
||||||
|
overall_effectiveness: null,
|
||||||
|
quality_of_education: null,
|
||||||
|
behaviour_attitudes: null,
|
||||||
|
personal_development: null,
|
||||||
|
leadership_management: null,
|
||||||
|
early_years_provision: null,
|
||||||
|
previous_overall: null,
|
||||||
|
rc_safeguarding_met: null,
|
||||||
|
rc_inclusion: null,
|
||||||
|
rc_curriculum_teaching: null,
|
||||||
|
rc_achievement: null,
|
||||||
|
rc_attendance_behaviour: null,
|
||||||
|
rc_personal_development: null,
|
||||||
|
rc_leadership_governance: null,
|
||||||
|
rc_early_years: null,
|
||||||
|
rc_sixth_form: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_CARD = {
|
||||||
|
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||||
|
rc_curriculum_teaching: { code: 2, label: 'Strong standard' },
|
||||||
|
rc_personal_development: { code: 2, label: 'Strong standard' },
|
||||||
|
rc_leadership_governance: { code: 2, label: 'Strong standard' },
|
||||||
|
rc_inclusion: { code: 3, label: 'Expected standard' },
|
||||||
|
rc_early_years: { code: 3, label: 'Expected standard' },
|
||||||
|
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('summariseReportCard', () => {
|
||||||
|
it('counts graded areas best-first and NAMES problem areas', () => {
|
||||||
|
const s = summariseReportCard(
|
||||||
|
ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }),
|
||||||
|
);
|
||||||
|
expect(s.counts).toEqual([
|
||||||
|
{ label: 'Strong standard', count: 4 },
|
||||||
|
{ label: 'Expected standard', count: 2 },
|
||||||
|
]);
|
||||||
|
expect(s.problems).toEqual([
|
||||||
|
{ areaLabel: 'Attendance & behaviour', label: 'Needs attention' },
|
||||||
|
]);
|
||||||
|
expect(s.safeguarding).toBe('met');
|
||||||
|
expect(s.allClear).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never counts safeguarding as a graded area', () => {
|
||||||
|
const s = summariseReportCard(
|
||||||
|
ofsted({
|
||||||
|
report_card: { rc_achievement: { code: 3, label: 'Expected standard' } },
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const total = s.counts.reduce((n, c) => n + c.count, 0);
|
||||||
|
expect(total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is allClear when everything is Expected standard or better and safeguarding met', () => {
|
||||||
|
const s = summariseReportCard(
|
||||||
|
ofsted({
|
||||||
|
report_card: {
|
||||||
|
rc_achievement: { code: 3, label: 'Expected standard' },
|
||||||
|
rc_inclusion: { code: 1, label: 'Exceptional' },
|
||||||
|
},
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(s.allClear).toBe(true);
|
||||||
|
expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes labels through from the API — never invents wording', () => {
|
||||||
|
const s = summariseReportCard(
|
||||||
|
ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(s)).not.toContain('Attention needed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ofstedDisplay', () => {
|
||||||
|
it('prefers the report card over any legacy grade', () => {
|
||||||
|
const d = ofstedDisplay(
|
||||||
|
ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }),
|
||||||
|
);
|
||||||
|
expect(d.kind).toBe('report_card');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes graded from carried-forward grades', () => {
|
||||||
|
const graded = ofstedDisplay(
|
||||||
|
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
||||||
|
);
|
||||||
|
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
|
||||||
|
|
||||||
|
const carried = ofstedDisplay(
|
||||||
|
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
||||||
|
);
|
||||||
|
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing data', () => {
|
||||||
|
expect(ofstedDisplay(null).kind).toBe('none');
|
||||||
|
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies transitional inspections without overall grades', () => {
|
||||||
|
const transitional = ofstedDisplay(
|
||||||
|
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
|
||||||
|
);
|
||||||
|
expect(transitional.kind).toBe('transitional');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the four legacy grade words', () => {
|
||||||
|
expect(OFSTED_LEGACY_GRADES).toEqual({
|
||||||
|
1: 'Outstanding',
|
||||||
|
2: 'Good',
|
||||||
|
3: 'Requires improvement',
|
||||||
|
4: 'Inadequate',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rcAreaLabel', () => {
|
||||||
|
it('maps rc keys to the mockups’ area labels', () => {
|
||||||
|
expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour');
|
||||||
|
expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching');
|
||||||
|
expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('summariseAdmissions', () => {
|
||||||
|
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||||
|
return {
|
||||||
|
year: 202627,
|
||||||
|
places_offered: null,
|
||||||
|
total_applications: null,
|
||||||
|
first_preference_offer_pct: null,
|
||||||
|
oversubscribed: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('97% → good chip with the mockup wording', () => {
|
||||||
|
const s = summariseAdmissions(
|
||||||
|
admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }),
|
||||||
|
);
|
||||||
|
expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' });
|
||||||
|
expect(s.interest).toBe('Named on 457 forms · 180 places');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('73% → warn chip "Over 1 in 4 first choices missed out"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('100% → "All first choices offered"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no data → null chip and interest', () => {
|
||||||
|
const s = summariseAdmissions(null);
|
||||||
|
expect(s.chip).toBeNull();
|
||||||
|
expect(s.interest).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admissionsForPhase', () => {
|
||||||
|
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
|
||||||
|
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
|
||||||
|
|
||||||
|
it('returns the latest round matching the active phase', () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(data, false)?.year).toBe(202526);
|
||||||
|
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses untagged legacy rows only when no row carries a phase', () => {
|
||||||
|
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
|
||||||
|
|
||||||
|
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(mixed, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing data', () => {
|
||||||
|
expect(admissionsForPhase(null, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progressBand', () => {
|
||||||
|
it('CI entirely above zero → above', () => {
|
||||||
|
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||||
|
});
|
||||||
|
it('CI entirely below zero → below', () => {
|
||||||
|
expect(progressBand(-1.2, -2.0, -0.4)).toBe('below');
|
||||||
|
});
|
||||||
|
it('CI straddling zero → average', () => {
|
||||||
|
expect(progressBand(0.3, -0.5, 1.1)).toBe('average');
|
||||||
|
});
|
||||||
|
it('missing CI → null (no naive thresholding)', () => {
|
||||||
|
expect(progressBand(1.2, null, null)).toBeNull();
|
||||||
|
expect(progressBand(null, null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verdict', () => {
|
||||||
|
it('above / close / below with a 2pp tolerance', () => {
|
||||||
|
expect(verdict(87, 62)).toBe('above');
|
||||||
|
expect(verdict(61, 62)).toBe('close');
|
||||||
|
expect(verdict(40, 62)).toBe('below');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripPositions', () => {
|
||||||
|
it('maps a custom domain', () => {
|
||||||
|
const pts = stripPositions([106], 100, 120);
|
||||||
|
expect(pts[0].pos).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips a colliding label above', () => {
|
||||||
|
const pts = stripPositions([91, 92], 0, 100);
|
||||||
|
const sorted = [...pts].sort((a, b) => a.value - b.value);
|
||||||
|
expect(sorted[0].labelAbove).toBe(false);
|
||||||
|
expect(sorted[1].labelAbove).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips nulls and keeps school indices', () => {
|
||||||
|
const pts = stripPositions([50, null, 70], 0, 100);
|
||||||
|
expect(pts).toHaveLength(2);
|
||||||
|
expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps out-of-domain values', () => {
|
||||||
|
const pts = stripPositions([95], 100, 120);
|
||||||
|
expect(pts[0].pos).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('latestValues', () => {
|
||||||
|
const data = {
|
||||||
|
'1': {
|
||||||
|
yearly_data: [
|
||||||
|
{ year: 202324, rwm_expected_pct: 75 },
|
||||||
|
{ year: 202425, rwm_expected_pct: 87 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
yearly_data: [
|
||||||
|
{ year: 202324, rwm_expected_pct: 82 },
|
||||||
|
{ year: 202425, rwm_expected_pct: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('takes the latest non-null value per school in urn order', async () => {
|
||||||
|
const { latestValues } = await import('@/lib/compareLogic');
|
||||||
|
expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown schools and metrics', async () => {
|
||||||
|
const { latestValues } = await import('@/lib/compareLogic');
|
||||||
|
expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]);
|
||||||
|
expect(latestValues(data, [1], 'nope')).toEqual([null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
isValidPostcode,
|
isValidPostcode,
|
||||||
debounce,
|
debounce,
|
||||||
buildOfstedListBadge,
|
buildOfstedListBadge,
|
||||||
|
metricKind,
|
||||||
|
shortName,
|
||||||
|
computeYBounds,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
|
||||||
describe('formatPercentage', () => {
|
describe('formatPercentage', () => {
|
||||||
@@ -19,7 +22,7 @@ describe('formatPercentage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles null values', () => {
|
it('handles null values', () => {
|
||||||
expect(formatPercentage(null)).toBe('-');
|
expect(formatPercentage(null)).toBe('N/A');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ describe('formatProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles null values', () => {
|
it('handles null values', () => {
|
||||||
expect(formatProgress(null)).toBe('-');
|
expect(formatProgress(null)).toBe('N/A');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,16 +47,16 @@ describe('calculateTrend', () => {
|
|||||||
expect(calculateTrend(70, 75)).toBe('down');
|
expect(calculateTrend(70, 75)).toBe('down');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates same trend', () => {
|
it('calculates stable trend', () => {
|
||||||
expect(calculateTrend(75, 75)).toBe('same');
|
expect(calculateTrend(75, 75)).toBe('stable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles null previous value', () => {
|
it('handles null previous value', () => {
|
||||||
expect(calculateTrend(75, null)).toBe('same');
|
expect(calculateTrend(75, null)).toBe('stable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles null current value', () => {
|
it('handles null current value', () => {
|
||||||
expect(calculateTrend(null, 75)).toBe('same');
|
expect(calculateTrend(null, 75)).toBe('stable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +75,13 @@ describe('isValidPostcode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('debounce', () => {
|
describe('debounce', () => {
|
||||||
jest.useFakeTimers();
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('delays function execution', () => {
|
it('delays function execution', () => {
|
||||||
const mockFn = jest.fn();
|
const mockFn = jest.fn();
|
||||||
@@ -100,8 +109,6 @@ describe('debounce', () => {
|
|||||||
expect(mockFn).toHaveBeenCalledWith('third');
|
expect(mockFn).toHaveBeenCalledWith('third');
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildOfstedListBadge', () => {
|
describe('buildOfstedListBadge', () => {
|
||||||
@@ -129,7 +136,21 @@ describe('buildOfstedListBadge', () => {
|
|||||||
expect(badge.cssClass).toBe('ofstedRc');
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns pending badge when no grade and no ReportCard framework', () => {
|
it('returns an "Inspected" badge for an OEIF inspection with no overall grade (post-Sept-2024)', () => {
|
||||||
|
// Inspected after Sept 2024: inspection on record (date + framework) but
|
||||||
|
// Ofsted no longer issues an overall grade. Must NOT read as "Not yet inspected".
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2024-11-01', ofsted_framework: 'OEIF' });
|
||||||
|
expect(badge.label).toBe('Inspected · 2024');
|
||||||
|
expect(badge.cssClass).toBe('ofstedInspected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an "Inspected" badge without a year when the grade is missing and date is absent but a record exists', () => {
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: 'OEIF' });
|
||||||
|
expect(badge.label).toBe('Inspected');
|
||||||
|
expect(badge.cssClass).toBe('ofstedInspected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending badge when no grade and no inspection on record', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
|
||||||
expect(badge.label).toBe('Not yet inspected');
|
expect(badge.label).toBe('Not yet inspected');
|
||||||
expect(badge.cssClass).toBe('ofstedPending');
|
expect(badge.cssClass).toBe('ofstedPending');
|
||||||
@@ -141,3 +162,79 @@ describe('buildOfstedListBadge', () => {
|
|||||||
expect(badge.cssClass).toBe('ofstedPending');
|
expect(badge.cssClass).toBe('ofstedPending');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('metricKind', () => {
|
||||||
|
it('classifies metrics by key', () => {
|
||||||
|
expect(metricKind('rwm_expected_pct')).toBe('percentage');
|
||||||
|
expect(metricKind('absence_rate')).toBe('percentage');
|
||||||
|
expect(metricKind('reading_progress')).toBe('progress');
|
||||||
|
expect(metricKind('progress_8_score')).toBe('progress');
|
||||||
|
expect(metricKind('attainment_8_score')).toBe('score');
|
||||||
|
expect(metricKind('reading_avg_score')).toBe('score');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeYBounds', () => {
|
||||||
|
it('tightens clustered percentages instead of framing 0-100', () => {
|
||||||
|
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
|
||||||
|
expect(b.min).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(b.max).toBeLessThanOrEqual(100);
|
||||||
|
expect(b.min).toBeGreaterThan(50);
|
||||||
|
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never widens percentages beyond 0-100 for non-negative data', () => {
|
||||||
|
const b = computeYBounds([2, 5, 98], 'percentage');
|
||||||
|
expect(b.min).toBe(0);
|
||||||
|
expect(b.max).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clamp to zero when pct-named trend data is negative', () => {
|
||||||
|
const b = computeYBounds([-12, -3, 4], 'percentage');
|
||||||
|
expect(b.min).toBeLessThan(-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps progress bounds symmetric around zero', () => {
|
||||||
|
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
|
||||||
|
expect(b.min).toBe(-b.max!);
|
||||||
|
expect(b.min).toBeLessThanOrEqual(-1.2);
|
||||||
|
expect(b.max).toBeGreaterThanOrEqual(2.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits score metrics without a fixed frame', () => {
|
||||||
|
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
|
||||||
|
expect(b.min).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(b.min).toBeLessThanOrEqual(42.3);
|
||||||
|
expect(b.max).toBeGreaterThanOrEqual(51.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty bounds when there is no numeric data', () => {
|
||||||
|
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
|
||||||
|
expect(computeYBounds([], 'progress')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isProposedToClose', () => {
|
||||||
|
const { isProposedToClose } = require('@/lib/utils');
|
||||||
|
|
||||||
|
it('is true only for the exact GIAS proposed-to-close status', () => {
|
||||||
|
expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true);
|
||||||
|
expect(isProposedToClose({ status: 'Open' })).toBe(false);
|
||||||
|
expect(isProposedToClose({ status: null })).toBe(false);
|
||||||
|
expect(isProposedToClose({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shortName', () => {
|
||||||
|
it('drops the trailing establishment-type words', () => {
|
||||||
|
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||||
|
expect(shortName('Elmhurst Primary School')).toBe('Elmhurst');
|
||||||
|
expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's");
|
||||||
|
expect(shortName('Riverside Community Junior School')).toBe('Riverside');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a name that carries no type suffix, capping very long ones', () => {
|
||||||
|
expect(shortName('Beaver Road')).toBe('Beaver Road');
|
||||||
|
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'School Admissions Guide',
|
||||||
|
description:
|
||||||
|
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdmissionsPage() {
|
||||||
|
return <AdmissionsView />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Runtime proxy for /api/* → the FastAPI backend.
|
||||||
|
*
|
||||||
|
* This replaces the old next.config.js `rewrites()` proxy, whose destination
|
||||||
|
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
|
||||||
|
* time. Because one frontend image is promoted staging→prod, a baked hostname
|
||||||
|
* forced every environment to name the backend identically; a mismatch (e.g.
|
||||||
|
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
|
||||||
|
*
|
||||||
|
* A route handler reads process.env.FASTAPI_URL on each request, so the same
|
||||||
|
* image adapts to whatever the backend is called in each environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
|
||||||
|
function backendBase(): string {
|
||||||
|
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop-by-hop / length headers must not be copied across a proxy — undici has
|
||||||
|
// already decoded the body, so a stale content-encoding/length corrupts it.
|
||||||
|
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
||||||
|
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params;
|
||||||
|
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
|
||||||
|
const init: RequestInit & { duplex?: 'half' } = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
redirect: 'manual',
|
||||||
|
cache: 'no-store',
|
||||||
|
};
|
||||||
|
if (METHODS_WITH_BODY.has(req.method)) {
|
||||||
|
init.body = req.body;
|
||||||
|
init.duplex = 'half';
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, init);
|
||||||
|
} catch (err) {
|
||||||
|
// e.g. DNS failure or connection refused — surface a clean 502 instead of
|
||||||
|
// an opaque proxy crash so callers can degrade gracefully.
|
||||||
|
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseHeaders = new Headers(upstream.headers);
|
||||||
|
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
handler as GET,
|
||||||
|
handler as HEAD,
|
||||||
|
handler as POST,
|
||||||
|
handler as PUT,
|
||||||
|
handler as PATCH,
|
||||||
|
handler as DELETE,
|
||||||
|
handler as OPTIONS,
|
||||||
|
};
|
||||||
@@ -16,12 +16,14 @@ interface ComparePageProps {
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Compare Schools',
|
title: 'Compare Schools',
|
||||||
description: 'Compare KS2 performance across multiple primary schools in England',
|
description:
|
||||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||||
|
keywords:
|
||||||
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
export const dynamic = 'force-dynamic';
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|
||||||
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||||
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
||||||
@@ -30,26 +32,24 @@ export default async function ComparePage({ searchParams }: ComparePageProps) {
|
|||||||
const selectedMetric = metricParam || 'rwm_expected_pct';
|
const selectedMetric = metricParam || 'rwm_expected_pct';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch comparison data if URNs provided
|
// Fetch comparison + metrics in parallel — they are independent.
|
||||||
let comparisonData = null;
|
const [comparisonResponse, metricsResponse] = await Promise.all([
|
||||||
if (urns.length > 0) {
|
urns.length > 0
|
||||||
try {
|
? fetchComparison(urnsParam!).catch((error) => {
|
||||||
const response = await fetchComparison(urnsParam!);
|
console.error('Failed to fetch comparison:', error);
|
||||||
comparisonData = response.comparison;
|
return null;
|
||||||
} catch (error) {
|
})
|
||||||
console.error('Failed to fetch comparison:', error);
|
: Promise.resolve(null),
|
||||||
}
|
fetchMetrics(),
|
||||||
}
|
]);
|
||||||
|
|
||||||
// Fetch available metrics
|
|
||||||
const metricsResponse = await fetchMetrics();
|
|
||||||
|
|
||||||
// Metrics is already an array
|
|
||||||
const metricsArray = metricsResponse?.metrics || [];
|
const metricsArray = metricsResponse?.metrics || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComparisonView
|
<ComparisonView
|
||||||
initialData={comparisonData}
|
initialData={comparisonResponse?.comparison ?? null}
|
||||||
|
initialNationalAverages={comparisonResponse?.national_averages}
|
||||||
|
initialBenchmarks={comparisonResponse?.benchmarks}
|
||||||
initialUrns={urns}
|
initialUrns={urns}
|
||||||
metrics={metricsArray}
|
metrics={metricsArray}
|
||||||
selectedMetric={selectedMetric}
|
selectedMetric={selectedMetric}
|
||||||
|
|||||||
+44
-19
@@ -16,8 +16,9 @@
|
|||||||
--text-inverse: #faf7f2;
|
--text-inverse: #faf7f2;
|
||||||
|
|
||||||
--accent-coral: #e07256;
|
--accent-coral: #e07256;
|
||||||
--accent-coral-dark: #c45a3f;
|
--accent-coral-dark: #b04a2e; /* AA: 4.5+ as text on white/cream tints; 5.4:1 under white text */
|
||||||
--accent-teal: #2d7d7d;
|
--accent-coral-darker: #9c3f26; /* hover for coral-dark fills */
|
||||||
|
--accent-teal: #296f6f; /* AA: 4.8+ as text on the cream/tint backgrounds */
|
||||||
--accent-teal-light: #3a9e9e;
|
--accent-teal-light: #3a9e9e;
|
||||||
--accent-gold: #c9a227;
|
--accent-gold: #c9a227;
|
||||||
--accent-gold-text: #7a6800; /* WCAG AA safe for text on white/cream */
|
--accent-gold-text: #7a6800; /* WCAG AA safe for text on white/cream */
|
||||||
@@ -92,7 +93,31 @@ body {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
/* dvh (dynamic viewport) accounts for iOS Safari's collapsing toolbar;
|
||||||
|
fall back to vh on older engines that don't recognise dvh. */
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
/* Suppress the iOS Safari grey tap flash — explicit :active states
|
||||||
|
below carry the press feedback instead. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provide a baseline press feedback for the most common interactive
|
||||||
|
elements — replaces the suppressed default tap highlight. Buttons and
|
||||||
|
.btn-* classes carry their own :active styles already; this handles
|
||||||
|
plain anchors used as inline links and bare button elements. */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
a:active,
|
||||||
|
button:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserve space for the fixed mobile bottom tab bar (56px + safe-area inset). */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skip link — visible only on focus for keyboard users */
|
/* Skip link — visible only on focus for keyboard users */
|
||||||
@@ -148,13 +173,13 @@ body {
|
|||||||
|
|
||||||
/* Primary: coral background — main CTAs (Search, Compare Now) */
|
/* Primary: coral background — main CTAs (Search, Compare Now) */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent-coral);
|
background: var(--accent-coral-dark);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent-coral);
|
border-color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--accent-coral-dark);
|
background: var(--accent-coral-darker);
|
||||||
border-color: var(--accent-coral-dark);
|
border-color: var(--accent-coral-darker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: teal outline — supporting actions (+ Compare) */
|
/* Secondary: teal outline — supporting actions (+ Compare) */
|
||||||
@@ -186,8 +211,8 @@ body {
|
|||||||
}
|
}
|
||||||
.btn-active:hover:not(:disabled) {
|
.btn-active:hover:not(:disabled) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
border-color: var(--accent-coral);
|
border-color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small variant */
|
/* Small variant */
|
||||||
@@ -721,14 +746,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-compare {
|
.btn-compare {
|
||||||
background: var(--accent-coral);
|
background: var(--accent-coral-dark);
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid var(--accent-coral);
|
border: 1px solid var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-compare:hover {
|
.btn-compare:hover {
|
||||||
background: #d4654a;
|
background: var(--accent-coral-darker);
|
||||||
border-color: #d4654a;
|
border-color: var(--accent-coral-darker);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-compare.active {
|
.btn-compare.active {
|
||||||
@@ -892,7 +917,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.negative {
|
.stat-value.negative {
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trend indicators */
|
/* Trend indicators */
|
||||||
@@ -906,7 +931,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-down {
|
.trend-down {
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-stable {
|
.trend-stable {
|
||||||
@@ -994,7 +1019,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-modal-close:hover {
|
.map-modal-close:hover {
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-modal-content {
|
.map-modal-content {
|
||||||
@@ -1543,12 +1568,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent-coral);
|
background: var(--accent-coral-dark);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--accent-coral-dark);
|
background: var(--accent-coral-darker);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,7 +1784,7 @@ body {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100dvh - 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -1829,7 +1854,7 @@ body {
|
|||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
max-height: calc(100vh - 1rem);
|
max-height: calc(100dvh - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import { Navigation } from '@/components/Navigation';
|
import { Navigation } from '@/components/Navigation';
|
||||||
@@ -21,7 +21,24 @@ const playfairDisplay = Playfair_Display({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||||
|
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: 'SchoolCompare',
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
default: 'SchoolCompare | Compare School Performance',
|
default: 'SchoolCompare | Compare School Performance',
|
||||||
template: '%s | SchoolCompare',
|
template: '%s | SchoolCompare',
|
||||||
@@ -57,10 +74,15 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||||
|
{/* data-domains: the tracker only fires on the production hostnames,
|
||||||
|
so staging (same image, different host) never pollutes Umami */}
|
||||||
<Script
|
<Script
|
||||||
defer
|
|
||||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||||
|
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||||
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+15
-6
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||||
|
import { EditorialSection } from '@/components/EditorialSection';
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -27,8 +29,9 @@ export const metadata = {
|
|||||||
description: 'Search and compare school performance across England',
|
description: 'Search and compare school performance across England',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation at build time)
|
// The page reads searchParams, which makes rendering dynamic by default.
|
||||||
export const dynamic = 'force-dynamic';
|
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
||||||
|
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
||||||
|
|
||||||
export default async function HomePage({ searchParams }: HomePageProps) {
|
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||||
// Await search params (Next.js 15 requirement)
|
// Await search params (Next.js 15 requirement)
|
||||||
@@ -75,22 +78,28 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
|
const total = dataInfo?.total_schools ?? null;
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
filters={resolvedFilters}
|
||||||
totalSchools={dataInfo?.total_schools ?? null}
|
totalSchools={total}
|
||||||
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data for home page:', error);
|
console.error('Error fetching data for home page:', error);
|
||||||
|
|
||||||
// Return error state with empty data
|
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
filters={emptyFilters}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export const metadata: Metadata = {
|
|||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
export const dynamic = 'force-dynamic';
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|
||||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||||
|
|||||||
@@ -4,13 +4,48 @@
|
|||||||
* URL format: /school/138267-school-name-here
|
* URL format: /school/138267-school-name-here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchSchoolDetails } from '@/lib/api';
|
import { fetchSchoolDetails, fetchSchools } from '@/lib/api';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate every school for static generation at build time.
|
||||||
|
*
|
||||||
|
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
|
||||||
|
* (or when the API can't be reached), we return an empty list and the route
|
||||||
|
* falls back to ISR on first request — `dynamicParams = true` covers it.
|
||||||
|
*/
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
|
||||||
|
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
|
||||||
|
|
||||||
|
const params: Array<{ slug: string }> = [];
|
||||||
|
const PAGE_SIZE = 500;
|
||||||
|
let page = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
|
||||||
|
for (const s of res.schools) {
|
||||||
|
const path = schoolUrl(s.urn, s.school_name);
|
||||||
|
const slug = path.replace('/school/', '');
|
||||||
|
params.push({ slug });
|
||||||
|
}
|
||||||
|
totalPages = res.total_pages || 1;
|
||||||
|
page += 1;
|
||||||
|
} while (page <= totalPages);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
interface SchoolPageProps {
|
interface SchoolPageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
@@ -75,8 +110,10 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force dynamic rendering
|
// ISR: regenerate at most once a week per slug. School data updates annually,
|
||||||
export const dynamic = 'force-dynamic';
|
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
@@ -96,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -152,7 +189,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
parentView={parent_view ?? null}
|
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
senDetail={sen_detail ?? null}
|
senDetail={sen_detail ?? null}
|
||||||
@@ -166,9 +202,9 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
parentView={parent_view ?? null}
|
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
|
admissionsHistory={admissions_history ?? []}
|
||||||
senDetail={sen_detail ?? null}
|
senDetail={sen_detail ?? null}
|
||||||
phonics={phonics ?? null}
|
phonics={phonics ?? null}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
||||||
|
*
|
||||||
|
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
||||||
|
* baking the backend host into the build, so one image works in every
|
||||||
|
* environment. robots.ts points crawlers here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function backendOrigin(): string {
|
||||||
|
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||||
|
return base.replace(/\/api$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
||||||
|
} catch {
|
||||||
|
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await upstream.text();
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wrapper {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdmissionsTrendChart
|
||||||
|
* Compact line chart of the first-choice offer rate across admissions years.
|
||||||
|
* Renders nothing unless at least two years carry an offer-rate value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { ChartOptions } from 'chart.js';
|
||||||
|
import '@/lib/chartSetup';
|
||||||
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
|
import styles from './AdmissionsTrendChart.module.css';
|
||||||
|
|
||||||
|
export default function AdmissionsTrendChart({ history }: { history: SchoolAdmissions[] }) {
|
||||||
|
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
|
const labels = pts.map((p) => formatAcademicYear(p.year));
|
||||||
|
const values = pts.map((p) => p.first_preference_offer_pct as number);
|
||||||
|
const lastIdx = pts.length - 1;
|
||||||
|
|
||||||
|
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
const options: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
// Headroom so a point sitting on the y-max ceiling (e.g. 100%) isn't
|
||||||
|
// clipped by the top of the plot area.
|
||||||
|
layout: { padding: { top: 8 } },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||||
|
padding: 10,
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 12 },
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => (ctx.parsed.y == null ? '' : `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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'First-choice offer rate',
|
||||||
|
data: values,
|
||||||
|
clip: false as const,
|
||||||
|
borderColor: '#e07256',
|
||||||
|
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||||
|
pointBackgroundColor: '#e07256',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Line data={data} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
.shell {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.25rem 4rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px minmax(0, 1fr);
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── In-page nav rail ────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
padding-top: 3.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
margin-left: -2px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink:hover {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
border-left-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive .navDot {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Hero ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrowDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 2.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSub {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Countdown strip ────────────────────────────────── */
|
||||||
|
|
||||||
|
.countdownSection {
|
||||||
|
padding: 0 0 2.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripHeader {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownRail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||||
|
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.chipUrgent {
|
||||||
|
border-color: rgba(224, 114, 86, 0.4);
|
||||||
|
background: rgba(224, 114, 86, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||||
|
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.chipTrackDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDays {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDeadline .chipDays,
|
||||||
|
.chipUrgent .chipDays {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipOffer .chipDays {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDaysUnit {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
vertical-align: bottom;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMilestone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Track (Secondary / Primary) ────────────────────── */
|
||||||
|
|
||||||
|
.track {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.75rem 2rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeaderLeft {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackKicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackSub {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateLabel {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateVal {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Timeline ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
padding: 1.5rem 2rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDotCol {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepDot {
|
||||||
|
background: rgba(224, 114, 86, 0.15);
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepOffer .stepDot {
|
||||||
|
background: rgba(45, 125, 125, 0.15);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLine {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 24px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepContent {
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:last-child .stepContent {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDate {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||||
|
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.stepTitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||||
|
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.stepBody {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepTool {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: gap 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepTool:hover {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepToolArrow {
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tips ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsHeading {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipCard {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipNumber {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipHeading {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipBody {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
background: var(--bg-primary, #faf8f3);
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
margin: 0 -1.25rem 1rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-left: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
margin-left: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
background: var(--text-primary, #1a1612);
|
||||||
|
color: #fff;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive .navDot {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.heroTitle {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownRail {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDates {
|
||||||
|
text-align: left;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import styles from './AdmissionsView.module.css';
|
||||||
|
|
||||||
|
/* ─── Date helpers ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function daysUntil(month: number, day: number): number {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
let target = new Date(y, month - 1, day);
|
||||||
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||||
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDate(month: number, day: number): Date {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
const target = new Date(y, month - 1, day);
|
||||||
|
if (target <= today) return new Date(y + 1, month - 1, day);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(month: number, day: number): string {
|
||||||
|
return nextDate(month, day).toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Data ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface Chip {
|
||||||
|
type: 'deadline' | 'offer';
|
||||||
|
track: string;
|
||||||
|
milestone: string;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIPS: Chip[] = [
|
||||||
|
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||||
|
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||||
|
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||||||
|
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
date?: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
highlight?: 'deadline' | 'offer';
|
||||||
|
/* How SchoolCompare helps at this stage of the journey */
|
||||||
|
tool?: { label: string; href: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECONDARY_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
title: 'Check entry criteria',
|
||||||
|
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
||||||
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: 'September',
|
||||||
|
title: 'Portal opens',
|
||||||
|
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||||
|
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '31 October',
|
||||||
|
title: 'Application deadline',
|
||||||
|
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
|
||||||
|
highlight: 'deadline',
|
||||||
|
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '1 March',
|
||||||
|
title: 'National Offer Day',
|
||||||
|
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
||||||
|
highlight: 'offer',
|
||||||
|
tool: { label: 'Look up your allocated school', href: '/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '~15 March',
|
||||||
|
title: 'Accept or decline',
|
||||||
|
body: 'Respond by the deadline your council gives — typically around 15 March. Accepting does not prevent you from keeping a place on a waiting list for a preferred school.',
|
||||||
|
tool: { label: 'Weigh your offer against your other choices', href: '/compare' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Appeals',
|
||||||
|
body: 'If unsuccessful, you can appeal within 20 school days of the refusal letter. Secondary appeals consider whether prejudice to the school outweighs your case — success rates vary.',
|
||||||
|
tool: { label: 'Gather performance evidence for your case', href: '/rankings' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIMARY_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
title: 'Research entry criteria',
|
||||||
|
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
||||||
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: 'September',
|
||||||
|
title: 'Portal opens',
|
||||||
|
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||||
|
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '15 January',
|
||||||
|
title: 'Application deadline',
|
||||||
|
body: 'List up to 3–6 schools (the number varies by council) in genuine preference order. The equal preference rule means all preferences are considered before any offers are made.',
|
||||||
|
highlight: 'deadline',
|
||||||
|
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '16 April',
|
||||||
|
title: 'National Offer Day',
|
||||||
|
body: 'Results are published online. Reception offers are sent on 16 April (or the next working day if that falls on a weekend or bank holiday).',
|
||||||
|
highlight: 'offer',
|
||||||
|
tool: { label: 'Look up your allocated school', href: '/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '~1 May',
|
||||||
|
title: 'Accept or decline',
|
||||||
|
body: 'Respond by your council\'s deadline, typically around 1 May. Accepting secures the place while you wait to see if a preferred school\'s waiting list moves.',
|
||||||
|
tool: { label: 'Weigh your offer against your other choices', href: '/compare' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Appeals',
|
||||||
|
body: 'Infant class-size appeals (Reception to Year 2) have a very narrow legal test and a low success rate. For Year 3+, appeals follow the same process as secondary.',
|
||||||
|
tool: { label: 'Gather performance evidence for your case', href: '/rankings' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Tip {
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIPS: Tip[] = [
|
||||||
|
{
|
||||||
|
heading: 'Equal preference rule',
|
||||||
|
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Late applications go to the back',
|
||||||
|
body: 'Submit before the deadline even if your child does not turn the required age until later in the year. Late applicants are only considered after all on-time applications.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Waiting lists',
|
||||||
|
body: 'You can go on waiting lists for multiple schools simultaneously. Lists are ordered by admissions criteria, not when you joined. They can move significantly over the summer.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Component ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'primary', label: 'Primary' },
|
||||||
|
{ id: 'secondary', label: 'Secondary' },
|
||||||
|
{ id: 'tips', label: 'Tips' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type NavId = typeof NAV_ITEMS[number]['id'];
|
||||||
|
|
||||||
|
export function AdmissionsView() {
|
||||||
|
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
|
||||||
|
const [activeId, setActiveId] = useState<NavId>('primary');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sections = NAV_ITEMS
|
||||||
|
.map(item => document.getElementById(item.id))
|
||||||
|
.filter((el): el is HTMLElement => el !== null);
|
||||||
|
|
||||||
|
if (sections.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter(e => e.isIntersecting)
|
||||||
|
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||||
|
if (visible[0]) setActiveId(visible[0].target.id as NavId);
|
||||||
|
},
|
||||||
|
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.forEach(s => observer.observe(s));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: NavId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - 16;
|
||||||
|
window.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
setActiveId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.shell}>
|
||||||
|
|
||||||
|
{/* In-page nav — sticky left rail on desktop, sticky top pills on mobile */}
|
||||||
|
<aside className={styles.nav} aria-label="On this page">
|
||||||
|
<div className={styles.navLabel}>On this page</div>
|
||||||
|
<ul className={styles.navList}>
|
||||||
|
{NAV_ITEMS.map(item => {
|
||||||
|
const isActive = activeId === item.id;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
onClick={(e) => handleNavClick(e, item.id)}
|
||||||
|
className={[styles.navLink, isActive ? styles.navLinkActive : ''].filter(Boolean).join(' ')}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<span className={styles.navDot} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className={styles.page}>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<span className={styles.eyebrow}>
|
||||||
|
<span className={styles.eyebrowDot} aria-hidden="true" />
|
||||||
|
England · Primary & Secondary
|
||||||
|
</span>
|
||||||
|
<h1 className={styles.heroTitle}>School Admissions Guide</h1>
|
||||||
|
<p className={styles.heroSub}>
|
||||||
|
Everything parents need to know about applying for a school place in England — from opening dates to National Offer Day, with live countdowns to every key milestone.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Countdown strip */}
|
||||||
|
<section className={styles.countdownSection}>
|
||||||
|
<div className={styles.stripHeader}>
|
||||||
|
<span className={styles.stripLabel}>Days until next milestone</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.countdownRail}>
|
||||||
|
{CHIPS.map((chip, i) => {
|
||||||
|
const days = chipDays[i];
|
||||||
|
const isUrgent = days !== null && days <= 14;
|
||||||
|
const chipClass = [
|
||||||
|
styles.chip,
|
||||||
|
chip.type === 'deadline' ? styles.chipDeadline : styles.chipOffer,
|
||||||
|
isUrgent ? styles.chipUrgent : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<div key={chip.milestone} className={chipClass}>
|
||||||
|
<span className={[styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer].join(' ')}>
|
||||||
|
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||||
|
{chip.track}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||||
|
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||||
|
<div className={styles.chipDate}>{days !== null ? fmtDate(chip.month, chip.day) : ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Primary track */}
|
||||||
|
<section id="primary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<div className={styles.trackHeader}>
|
||||||
|
<div className={styles.trackHeaderLeft}>
|
||||||
|
<span className={styles.trackKicker}>Reception entry</span>
|
||||||
|
<h2 className={styles.trackTitle}>Primary school admissions</h2>
|
||||||
|
<p className={styles.trackSub}>For children starting Reception (Year R) in September. Applications are submitted in the autumn of the year before entry.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDates}>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Deadline</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(1, 15)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(4, 16)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className={styles.timeline}>
|
||||||
|
{PRIMARY_STEPS.map((step, i) => (
|
||||||
|
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||||
|
<div className={styles.stepDotCol}>
|
||||||
|
<div className={styles.stepDot} />
|
||||||
|
{i < PRIMARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.tool && (
|
||||||
|
<a
|
||||||
|
href={step.tool.href}
|
||||||
|
className={styles.stepTool}
|
||||||
|
data-umami-event="admissions_tool_clicked"
|
||||||
|
data-umami-event-target={step.tool.href}
|
||||||
|
>
|
||||||
|
{step.tool.label}
|
||||||
|
<span className={styles.stepToolArrow} aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Secondary track */}
|
||||||
|
<section id="secondary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<div className={styles.trackHeader}>
|
||||||
|
<div className={styles.trackHeaderLeft}>
|
||||||
|
<span className={styles.trackKicker}>Year 7 entry</span>
|
||||||
|
<h2 className={styles.trackTitle}>Secondary school admissions</h2>
|
||||||
|
<p className={styles.trackSub}>For children starting secondary school (Year 7) in September. Applications are submitted in the autumn of Year 6.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDates}>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Deadline</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(10, 31)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(3, 1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className={styles.timeline}>
|
||||||
|
{SECONDARY_STEPS.map((step, i) => (
|
||||||
|
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||||
|
<div className={styles.stepDotCol}>
|
||||||
|
<div className={styles.stepDot} />
|
||||||
|
{i < SECONDARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.tool && (
|
||||||
|
<a
|
||||||
|
href={step.tool.href}
|
||||||
|
className={styles.stepTool}
|
||||||
|
data-umami-event="admissions_tool_clicked"
|
||||||
|
data-umami-event-target={step.tool.href}
|
||||||
|
>
|
||||||
|
{step.tool.label}
|
||||||
|
<span className={styles.stepToolArrow} aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<section id="tips" className={styles.tips} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<h2 className={styles.tipsHeading}>Three things most parents get wrong</h2>
|
||||||
|
<div className={styles.tipsGrid}>
|
||||||
|
{TIPS.map((tip, i) => (
|
||||||
|
<div key={i} className={styles.tipCard}>
|
||||||
|
<div className={styles.tipNumber}>{String(i + 1).padStart(2, '0')}</div>
|
||||||
|
<h3 className={styles.tipHeading}>{tip.heading}</h3>
|
||||||
|
<p className={styles.tipBody}>{tip.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
|
||||||
|
parent .chartContainer, whose fixed height drives Chart.js sizing via
|
||||||
|
maintainAspectRatio: false. */
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasBox {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
||||||
|
Chart.js's built-in legend (with per-school point shapes). */
|
||||||
|
.chips {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chips {
|
||||||
|
/* Two chips per row so long school names don't crowd into a single
|
||||||
|
line; each chip fills its column and truncates with an ellipsis. */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, .12);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed="true"] {
|
||||||
|
background: rgba(0, 0, 0, .06);
|
||||||
|
border-color: rgba(0, 0, 0, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipName {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* min-width:0 lets the name shrink inside the grid cell so the
|
||||||
|
ellipsis kicks in instead of overflowing. */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartNote {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
@@ -1,80 +1,115 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonChart Component
|
* ComparisonChart Component
|
||||||
* Multi-school comparison chart using Chart.js
|
* Multi-school comparison chart using Chart.js.
|
||||||
|
*
|
||||||
|
* Desktop: built-in legend (point-style markers double as per-school shapes).
|
||||||
|
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
|
||||||
|
* of a chip row above the canvas; tapping a chip highlights that school's
|
||||||
|
* line and dims the rest. The y-axis auto-fits the data on all viewports so
|
||||||
|
* clustered schools stay distinguishable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import {
|
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||||
Chart as ChartJS,
|
import '@/lib/chartSetup';
|
||||||
CategoryScale,
|
import { buildCompareChart } from '@/lib/compareChartData';
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ChartOptions,
|
|
||||||
} from 'chart.js';
|
|
||||||
import type { ComparisonData } from '@/lib/types';
|
import type { ComparisonData } from '@/lib/types';
|
||||||
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
import {
|
||||||
|
CHART_COLORS,
|
||||||
// Register Chart.js components
|
CHART_TEXT_COLORS,
|
||||||
ChartJS.register(
|
computeYBounds,
|
||||||
CategoryScale,
|
formatAcademicYear,
|
||||||
LinearScale,
|
metricKind,
|
||||||
PointElement,
|
rgbToRgba,
|
||||||
LineElement,
|
} from '@/lib/utils';
|
||||||
Title,
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
Tooltip,
|
import { track } from '@/lib/analytics';
|
||||||
Legend
|
import styles from './ComparisonChart.module.css';
|
||||||
);
|
|
||||||
|
|
||||||
interface ComparisonChartProps {
|
interface ComparisonChartProps {
|
||||||
comparisonData: Record<string, ComparisonData>;
|
comparisonData: Record<string, ComparisonData>;
|
||||||
|
/** Ordered as displayed in the school cards, so colours match by index. */
|
||||||
|
schools: Array<{ urn: number; school_name: string }>;
|
||||||
metric: string;
|
metric: string;
|
||||||
metricLabel: string;
|
metricLabel: string;
|
||||||
|
/** Official England figure per academic year for this metric — renders a
|
||||||
|
* dashed grey reference line when provided. */
|
||||||
|
nationalByYear?: Record<number, number | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
|
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||||
// Get all schools and their data
|
// converging lines stay tellable apart without relying on hue alone.
|
||||||
const schools = Object.entries(comparisonData);
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||||
|
|
||||||
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// A focused school that leaves the basket must not linger.
|
||||||
|
const urnKey = schools.map((s) => s.urn).join(',');
|
||||||
|
useEffect(() => {
|
||||||
|
setFocusedUrn(null);
|
||||||
|
}, [urnKey]);
|
||||||
|
|
||||||
if (schools.length === 0) {
|
if (schools.length === 0) {
|
||||||
return <div>No data available</div>;
|
return <div>No data available</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get years from first school (assuming all schools have same years)
|
// Pure, tested series construction: union of years with cancelled /
|
||||||
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
|
// unpublished years kept as real gaps, plus the England overlay.
|
||||||
|
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
||||||
|
const { years } = built;
|
||||||
|
|
||||||
// Create datasets for each school
|
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||||
const datasets = schools.map(([urn, data], index) => {
|
const school = schools[series.schoolIndex];
|
||||||
const schoolInfo = data.school_info;
|
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: schoolInfo.school_name,
|
label: series.label,
|
||||||
data: years.map((year) => {
|
data: series.data,
|
||||||
const yearData = data.yearly_data.find((d) => d.year === year);
|
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||||||
if (!yearData) return null;
|
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
||||||
return yearData[metric as keyof typeof yearData] as number | null;
|
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
||||||
}),
|
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
||||||
borderColor: color,
|
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
pointHoverRadius: isMobile ? 5 : 6,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
spanGaps: true,
|
// Never bridge missing years — gaps are information (COVID
|
||||||
|
// cancellations, unpublished 2021/22, schools that opened later).
|
||||||
|
spanGaps: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (built.englandDataset) {
|
||||||
|
datasets.push({
|
||||||
|
label: built.englandDataset.label,
|
||||||
|
data: built.englandDataset.data,
|
||||||
|
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderDash: built.englandDataset.borderDash,
|
||||||
|
pointStyle: 'line',
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
tension: 0,
|
||||||
|
spanGaps: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: years.map(formatAcademicYear),
|
labels: years.map(formatAcademicYear),
|
||||||
datasets,
|
datasets,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if metric is a progress score or percentage
|
const kind = metricKind(metric);
|
||||||
const isProgressScore = metric.includes('progress');
|
const yBounds = computeYBounds(
|
||||||
const isPercentage = metric.includes('pct') || metric.includes('rate');
|
datasets.flatMap((ds) => ds.data as Array<number | null>),
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
|
||||||
const options: ChartOptions<'line'> = {
|
const options: ChartOptions<'line'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -85,6 +120,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
|
display: !isMobile,
|
||||||
position: 'top' as const,
|
position: 'top' as const,
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
@@ -94,26 +130,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// No in-chart title: the section heading and metric selector above the
|
||||||
|
// chart already state the metric.
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false,
|
||||||
text: `${metricLabel} - Comparison`,
|
|
||||||
font: {
|
|
||||||
size: 16,
|
|
||||||
weight: 'bold',
|
|
||||||
},
|
|
||||||
padding: {
|
|
||||||
bottom: 20,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
padding: 12,
|
padding: isMobile ? 10 : 12,
|
||||||
titleFont: {
|
titleFont: {
|
||||||
size: 14,
|
size: isMobile ? 12 : 14,
|
||||||
},
|
},
|
||||||
bodyFont: {
|
bodyFont: {
|
||||||
size: 13,
|
size: isMobile ? 11 : 13,
|
||||||
},
|
},
|
||||||
|
usePointStyle: true,
|
||||||
|
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context) {
|
label: function (context) {
|
||||||
let label = context.dataset.label || '';
|
let label = context.dataset.label || '';
|
||||||
@@ -121,13 +153,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
label += ': ';
|
label += ': ';
|
||||||
}
|
}
|
||||||
if (context.parsed.y !== null) {
|
if (context.parsed.y !== null) {
|
||||||
if (isProgressScore) {
|
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
|
||||||
label += context.parsed.y.toFixed(1);
|
|
||||||
} else if (isPercentage) {
|
|
||||||
label += context.parsed.y.toFixed(1) + '%';
|
|
||||||
} else {
|
|
||||||
label += context.parsed.y.toFixed(1);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
label += 'N/A';
|
label += 'N/A';
|
||||||
}
|
}
|
||||||
@@ -141,17 +167,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
type: 'linear' as const,
|
type: 'linear' as const,
|
||||||
display: true,
|
display: true,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: !isMobile,
|
||||||
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
|
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: 12,
|
||||||
weight: 'bold',
|
weight: 'bold',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isPercentage && {
|
...yBounds,
|
||||||
min: 0,
|
ticks: {
|
||||||
max: 100,
|
font: { size: isMobile ? 10 : 12 },
|
||||||
}),
|
...(isMobile && { maxTicksLimit: 5 }),
|
||||||
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
},
|
},
|
||||||
@@ -161,16 +188,64 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: !isMobile,
|
||||||
text: 'Year',
|
text: 'Year',
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: 12,
|
||||||
weight: 'bold',
|
weight: 'bold',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ticks: {
|
||||||
|
font: { size: isMobile ? 10 : 12 },
|
||||||
|
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Line data={chartData} options={options} />;
|
const toggleFocus = (urn: number) => {
|
||||||
|
const next = focusedUrn === urn ? null : urn;
|
||||||
|
setFocusedUrn(next);
|
||||||
|
if (next !== null) track('compare_focus_school', { urn: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{/* Mobile legend + focus control; a single series needs no legend. */}
|
||||||
|
{schools.length > 1 && (
|
||||||
|
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
|
||||||
|
{schools.map((school, index) => (
|
||||||
|
<button
|
||||||
|
key={school.urn}
|
||||||
|
type="button"
|
||||||
|
className={styles.chip}
|
||||||
|
aria-pressed={focusedUrn === school.urn}
|
||||||
|
onClick={() => toggleFocus(school.urn)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={styles.chipDot}
|
||||||
|
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={styles.chipName}
|
||||||
|
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
|
||||||
|
>
|
||||||
|
{school.school_name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.canvasBox}>
|
||||||
|
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||||
|
</div>
|
||||||
|
{built.showUnpublished202122Note && (
|
||||||
|
<p className={styles.chartNote}>
|
||||||
|
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
||||||
|
school-level figures for 2021/22 — the England average is shown for that year.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.removeSchoolBtn:hover {
|
.removeSchoolBtn:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastActions {
|
.toastActions {
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btnClearAll:hover {
|
.btnClearAll:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnCompare {
|
.btnCompare {
|
||||||
@@ -167,20 +167,12 @@
|
|||||||
background: var(--accent-coral-dark, #c9614a);
|
background: var(--accent-coral-dark, #c9614a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||||
|
communicates count + destination, so the toast becomes redundant
|
||||||
|
chrome that costs ~70px of permanent vertical space. Per-school
|
||||||
|
removal still lives on the /compare page itself. */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.toastContainer {
|
.toastContainer {
|
||||||
bottom: 1.5rem;
|
display: none;
|
||||||
width: calc(100% - 3rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastContent {
|
|
||||||
gap: 0;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastActions {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,15 @@
|
|||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Phase Tabs */
|
/* Phase Tabs */
|
||||||
.phaseTabs {
|
.phaseTabs {
|
||||||
@@ -63,405 +70,193 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive {
|
.phaseTabActive {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive:hover {
|
.phaseTabActive:hover {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric Selector */
|
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||||
.metricSelector {
|
narrow screens. Offset by the sticky site header's height (Navigation is
|
||||||
background: var(--bg-card, white);
|
position: sticky, top: 0) so this bar pins just below it instead of
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
|
||||||
border-radius: 12px;
|
.schoolBar {
|
||||||
padding: 1.5rem;
|
position: sticky;
|
||||||
margin-bottom: 2rem;
|
top: 65px;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-primary, #faf7f2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricLabel {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect:hover {
|
|
||||||
border-color: var(--accent-coral, #e07256);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-coral, #e07256);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect optgroup {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect option {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
padding: 0.375rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schools Section */
|
|
||||||
.schoolsSection {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolsGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolCard {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolCard:hover {
|
|
||||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.75rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--accent-coral, #e07256);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton:hover {
|
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding-right: 2rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName a {
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName a:hover {
|
|
||||||
color: var(--accent-coral, #e07256);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaItem {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestValue {
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
text-align: center;
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
margin-left: -1.5rem;
|
|
||||||
margin-right: -1.5rem;
|
|
||||||
margin-bottom: -1.5rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-radius: 0 0 12px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestLabel {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted, #8a847a);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestNumber {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-teal, #2d7d7d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chart Section */
|
|
||||||
.chartSection {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
height: 1em;
|
|
||||||
background: var(--accent-coral, #e07256);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartContainer {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingMessage {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table Section */
|
|
||||||
.tableSection {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableWrapper {
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
padding: 0.75rem 0;
|
||||||
margin-top: 1rem;
|
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable {
|
.schoolChip {
|
||||||
width: 100%;
|
flex: 1 1 0;
|
||||||
border-collapse: separate;
|
min-width: 180px;
|
||||||
border-spacing: 0;
|
background: var(--bg-card, white);
|
||||||
font-size: 0.9375rem;
|
border: 1px solid var(--border-light, #e5dfd5);
|
||||||
|
border-top: 3px solid var(--accent-coral, #e07256);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable thead {
|
.chipDot {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable th {
|
.chipText {
|
||||||
padding: 1rem;
|
min-width: 0;
|
||||||
text-align: left;
|
}
|
||||||
|
|
||||||
|
.chipName {
|
||||||
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.25;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
text-decoration: none;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
}
|
||||||
|
|
||||||
|
.chipName:hover {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full name on desktop, short name on the compact mobile pills. */
|
||||||
|
.chipNameShort {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMeta {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: uppercase;
|
overflow: hidden;
|
||||||
font-size: 0.75rem;
|
text-overflow: ellipsis;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable td {
|
/* Caption filling the label rail on desktop ("Comparing / 3 primary
|
||||||
padding: 1rem;
|
schools"). Hidden on mobile, where the bar is a row of compact pills. */
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
.barCaption {
|
||||||
color: var(--text-secondary, #5c564d);
|
display: none;
|
||||||
text-align: left;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky first column (Year) so labels remain visible while scrolling */
|
/* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
|
||||||
.comparisonTable th:first-child,
|
grid template as compareSections' .grid — a 200px row-label rail plus one
|
||||||
.comparisonTable td:first-child {
|
column per school — so each chip sits exactly over the column it labels.
|
||||||
position: sticky;
|
The caption occupies the rail; chips flow into the school columns. */
|
||||||
left: 0;
|
@media (min-width: 761px) {
|
||||||
z-index: 1;
|
.schoolBar {
|
||||||
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
|
display: grid;
|
||||||
}
|
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||||
|
gap: 0 0.75rem;
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.comparisonTable thead th:first-child {
|
.schoolChip {
|
||||||
z-index: 2;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable tbody tr:hover td:first-child {
|
.barCaption {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
grid-column: 1;
|
||||||
}
|
display: flex;
|
||||||
|
|
||||||
.comparisonTable tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable tbody tr:hover {
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.yearCell {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-gold, #c9a227);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.emptyState {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyStateTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyStateDescription {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricDescription {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
max-width: 600px;
|
|
||||||
flex-basis: 100%;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressNote {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-left: 3px solid var(--accent-teal);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.headerContent {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.barCaptionEyebrow {
|
||||||
font-size: 1.75rem;
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metricSelector {
|
.barCaptionCount {
|
||||||
flex-direction: column;
|
font-size: 0.95rem;
|
||||||
align-items: stretch;
|
font-weight: 600;
|
||||||
padding: 1rem;
|
line-height: 1.3;
|
||||||
border-radius: 8px;
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.metricSelect {
|
.chipRemove {
|
||||||
max-width: 100%;
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||||
|
padding-top: 1rem;
|
||||||
|
max-width: 75ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: the sticky school bar becomes compact, horizontally-scrollable
|
||||||
|
pills with short names (matching the mobile mockup) instead of full-width
|
||||||
|
cards whose names wrap to several lines. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* The mobile Navigation header is shorter (≈57px). */
|
||||||
|
.schoolBar {
|
||||||
|
top: 57px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolsGrid {
|
.schoolChip {
|
||||||
grid-template-columns: 1fr;
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
border-top-width: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartSection,
|
.chipName {
|
||||||
.tableSection {
|
font-size: 0.85rem;
|
||||||
padding: 1rem;
|
white-space: nowrap;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartContainer {
|
.chipNameFull {
|
||||||
height: 300px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable {
|
.chipNameShort {
|
||||||
font-size: 0.875rem;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable th,
|
.chipMeta {
|
||||||
.comparisonTable td {
|
display: none;
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.latestValue {
|
.chipRemove {
|
||||||
margin-left: -1rem;
|
width: 18px;
|
||||||
margin-right: -1rem;
|
height: 18px;
|
||||||
margin-bottom: -1rem;
|
font-size: 0.75rem;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0 0 8px 5px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonView Component
|
* ComparisonView — the parent-first compare screen: a sticky school bar and
|
||||||
* Client-side comparison interface with phase tabs, charts, and tables
|
* six sections (At a glance / Ofsted / Academics / Getting a place / Who
|
||||||
|
* goes there / Explore trends), every number anchored against the England
|
||||||
|
* average or the computed state-school benchmark with provenance-correct
|
||||||
|
* labels. Layout and copy follow the reviewed mockups
|
||||||
|
* (docs/superpowers/specs/mockups/).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import { ComparisonChart } from './ComparisonChart';
|
|
||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { CompareAtAGlance } from './compare/CompareAtAGlance';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import { CompareOfsted } from './compare/CompareOfsted';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
import { CompareAcademics } from './compare/CompareAcademics';
|
||||||
|
import { CompareAdmissions } from './compare/CompareAdmissions';
|
||||||
|
import { CompareCommunity } from './compare/CompareCommunity';
|
||||||
|
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
|
||||||
|
import type {
|
||||||
|
Benchmarks,
|
||||||
|
ComparisonData,
|
||||||
|
MetricDefinition,
|
||||||
|
NationalAverages,
|
||||||
|
School,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
|
||||||
const SECONDARY_CATEGORIES = ['gcse'];
|
|
||||||
|
|
||||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
|
||||||
{ label: 'Expected Standard', category: 'expected' },
|
|
||||||
{ label: 'Higher Standard', category: 'higher' },
|
|
||||||
{ label: 'Progress Scores', category: 'progress' },
|
|
||||||
{ label: 'Average Scores', category: 'average' },
|
|
||||||
{ label: 'Gender Performance', category: 'gender' },
|
|
||||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
|
||||||
{ label: 'School Context', category: 'context' },
|
|
||||||
{ label: 'Absence', category: 'absence' },
|
|
||||||
{ label: '3-Year Trends', category: 'trends' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
|
||||||
{ label: 'GCSE Performance', category: 'gcse' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ComparisonViewProps {
|
interface ComparisonViewProps {
|
||||||
initialData: Record<string, ComparisonData> | null;
|
initialData: Record<string, ComparisonData> | null;
|
||||||
|
initialNationalAverages?: NationalAverages;
|
||||||
|
initialBenchmarks?: Benchmarks;
|
||||||
initialUrns: number[];
|
initialUrns: number[];
|
||||||
metrics: MetricDefinition[];
|
metrics: MetricDefinition[];
|
||||||
selectedMetric: string;
|
selectedMetric: string;
|
||||||
@@ -45,6 +44,8 @@ interface ComparisonViewProps {
|
|||||||
|
|
||||||
export function ComparisonView({
|
export function ComparisonView({
|
||||||
initialData,
|
initialData,
|
||||||
|
initialNationalAverages,
|
||||||
|
initialBenchmarks,
|
||||||
initialUrns,
|
initialUrns,
|
||||||
metrics,
|
metrics,
|
||||||
selectedMetric: initialMetric,
|
selectedMetric: initialMetric,
|
||||||
@@ -52,34 +53,52 @@ export function ComparisonView({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { selectedSchools, removeSchool, addSchool, isInitialized } = useComparison();
|
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
||||||
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
const [comparisonData, setComparisonData] = useState(initialData);
|
||||||
|
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>(
|
||||||
|
initialNationalAverages,
|
||||||
|
);
|
||||||
|
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(initialBenchmarks);
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
|
// Tracks whether the user has explicitly clicked a phase tab.
|
||||||
|
// While true, auto-phase detection is suppressed so manual selections aren't overridden.
|
||||||
|
const phaseLockedByUser = useRef(false);
|
||||||
|
|
||||||
// Seed context from initialData when component mounts and localStorage is empty
|
// Seed context from the URL on mount. An explicit ?urns=… (e.g. a link a
|
||||||
|
// parent shared with their partner) always wins over this visitor's stored
|
||||||
|
// selection — otherwise the recipient silently sees their own old schools.
|
||||||
|
// The replacement is then persisted like any other selection change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
if (selectedSchools.length === 0 && initialUrns.length > 0 && initialData) {
|
if (initialUrns.length > 0 && initialData) {
|
||||||
initialUrns.forEach(urn => {
|
const urlSchools = initialUrns
|
||||||
const data = initialData[String(urn)];
|
.map((urn) => initialData[String(urn)]?.school_info)
|
||||||
if (data?.school_info) {
|
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
||||||
addSchool(data.school_info);
|
const sameSet =
|
||||||
}
|
urlSchools.length === selectedSchools.length &&
|
||||||
});
|
urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
|
||||||
|
if (urlSchools.length > 0 && !sameSet) {
|
||||||
|
replaceSchools(urlSchools);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
// Re-seed when a client-side navigation lands on a different ?urns= set
|
||||||
|
// (initialUrns/initialData are new props on the same component instance).
|
||||||
|
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Sync URL with selected schools
|
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
||||||
|
|
||||||
|
// Sync the URL with the selection + metric. Pure navigation state — no
|
||||||
|
// fetching here: metric changes are presentational (the data is already
|
||||||
|
// client-side) and must not refire the comparison request.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
if (urns) {
|
if (urnKey) {
|
||||||
params.set('urns', urns);
|
params.set('urns', urnKey);
|
||||||
} else {
|
} else {
|
||||||
params.delete('urns');
|
params.delete('urns');
|
||||||
}
|
}
|
||||||
@@ -88,87 +107,164 @@ export function ComparisonView({
|
|||||||
|
|
||||||
const newUrl = `${pathname}?${params.toString()}`;
|
const newUrl = `${pathname}?${params.toString()}`;
|
||||||
router.replace(newUrl, { scroll: false });
|
router.replace(newUrl, { scroll: false });
|
||||||
|
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
||||||
|
|
||||||
// Fetch comparison data
|
// Fetch when the school set changes, but only for schools we don't already
|
||||||
if (selectedSchools.length > 0) {
|
// have data for. This skips the refetch of SSR-rendered data on load AND
|
||||||
fetchComparison(urns, { cache: 'no-store' })
|
// avoids a network call when a school is merely removed. A ref holds the
|
||||||
.then((data) => {
|
// latest data so the effect can read it without re-running on every fetch.
|
||||||
setComparisonData(data.comparison);
|
//
|
||||||
})
|
// Correctness note: we must NOT null the data on a transient empty urnKey.
|
||||||
.catch((err) => {
|
// On mount the basket is empty for a beat before it hydrates from the URL,
|
||||||
console.error('Failed to fetch comparison:', err);
|
// and blanking here (then skipping the refetch because SSR "covers" the set)
|
||||||
setComparisonData(null);
|
// was leaving the page empty on refresh. The render already shows the empty
|
||||||
});
|
// state whenever `selectedSchools` is empty, so stale data for deselected
|
||||||
} else {
|
// schools is harmless — it's simply unused.
|
||||||
setComparisonData(null);
|
const comparisonDataRef = useRef(comparisonData);
|
||||||
}
|
comparisonDataRef.current = comparisonData;
|
||||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
|
||||||
|
|
||||||
// Classify schools by phase using comparison data
|
|
||||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
|
||||||
const info = comparisonData?.[school.urn]?.school_info;
|
|
||||||
if (info?.attainment_8_score != null) return 'secondary';
|
|
||||||
if (info?.rwm_expected_pct != null) return 'primary';
|
|
||||||
// Fallback: check yearly data
|
|
||||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
|
||||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
|
||||||
return 'primary';
|
|
||||||
};
|
|
||||||
|
|
||||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
|
||||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
|
||||||
|
|
||||||
// Auto-select tab with more schools
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (comparisonData && selectedSchools.length > 0) {
|
if (!isInitialized || !urnKey) return;
|
||||||
if (secondarySchools.length > primarySchools.length) {
|
|
||||||
setComparePhase('secondary');
|
const have = comparisonDataRef.current ?? {};
|
||||||
} else {
|
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||||
setComparePhase('primary');
|
if (covered) return;
|
||||||
}
|
|
||||||
|
// Guard against out-of-order responses: while the basket hydrates from
|
||||||
|
// localStorage it can transiently hold a DIFFERENT school set than the
|
||||||
|
// URL, firing a fetch for schools the user is no longer comparing. That
|
||||||
|
// stale response must not replace data for the current set — it blanked
|
||||||
|
// every section until a hard refresh. Cleanup marks the run cancelled
|
||||||
|
// when urnKey moves on, so only the current selection's response is
|
||||||
|
// applied (replacing the map keeps it bounded and guarantees a re-added
|
||||||
|
// school is refetched fresh rather than served a lingering old entry).
|
||||||
|
let cancelled = false;
|
||||||
|
fetchComparison(urnKey, { cache: 'no-store' })
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setComparisonData(data.comparison);
|
||||||
|
setNationalAverages(data.national_averages);
|
||||||
|
setBenchmarks(data.benchmarks);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Keep whatever we already have (SSR data or a previous fetch) rather
|
||||||
|
// than blanking the page — a transient refetch failure shouldn't
|
||||||
|
// destroy a working comparison the user is looking at.
|
||||||
|
console.error('Failed to fetch comparison:', err);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [urnKey, isInitialized]);
|
||||||
|
|
||||||
|
const primarySchools = selectedSchools.filter((school) => {
|
||||||
|
const info = comparisonData?.[school.urn]?.school_info;
|
||||||
|
const hasPrimaryData =
|
||||||
|
info?.rwm_expected_pct != null ||
|
||||||
|
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
|
||||||
|
if (hasPrimaryData) return true;
|
||||||
|
return school.phase?.toLowerCase().includes('primary') || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondarySchools = selectedSchools.filter((school) => {
|
||||||
|
const info = comparisonData?.[school.urn]?.school_info;
|
||||||
|
const hasSecondaryData =
|
||||||
|
info?.attainment_8_score != null ||
|
||||||
|
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
|
||||||
|
if (hasSecondaryData) return true;
|
||||||
|
return school.phase?.toLowerCase().includes('secondary') || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select tab with more schools and sync the metric to match the phase.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!comparisonData || selectedSchools.length === 0) return;
|
||||||
|
if (phaseLockedByUser.current) return;
|
||||||
|
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||||
|
setComparePhase(newPhase);
|
||||||
|
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||||
|
const metricFitsPhase = metrics.some(
|
||||||
|
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
|
||||||
|
);
|
||||||
|
if (!metricFitsPhase) {
|
||||||
|
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
}
|
}
|
||||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||||
|
// first run sees an empty basket and bails, so it must re-fire when the
|
||||||
|
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||||
|
// are intentionally omitted (derived or would cause loops).
|
||||||
|
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||||
|
phaseLockedByUser.current = true;
|
||||||
setComparePhase(phase);
|
setComparePhase(phase);
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
setSelectedMetric(defaultMetric);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
// compare_viewed: fire once after the page has its first selection.
|
||||||
setSelectedMetric(metric);
|
const compareViewedRef = useRef(false);
|
||||||
};
|
useEffect(() => {
|
||||||
|
if (compareViewedRef.current) return;
|
||||||
|
if (selectedSchools.length === 0) return;
|
||||||
|
compareViewedRef.current = true;
|
||||||
|
const primaryCount = selectedSchools.filter((s) =>
|
||||||
|
s.phase?.toLowerCase().includes('primary'),
|
||||||
|
).length;
|
||||||
|
const secondaryCount = selectedSchools.length - primaryCount;
|
||||||
|
const phaseMix =
|
||||||
|
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||||
|
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||||
|
}, [selectedSchools]);
|
||||||
|
|
||||||
const handleRemoveSchool = (urn: number) => {
|
const handleRemoveSchool = (urn: number) => {
|
||||||
removeSchool(urn);
|
removeSchool(urn);
|
||||||
|
track('compare_school_removed', { urn, from: 'compare' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
const count = selectedSchools.length;
|
||||||
|
const shareData = {
|
||||||
|
title: 'School comparison · SchoolCompare',
|
||||||
|
text:
|
||||||
|
count > 0
|
||||||
|
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||||
|
: 'SchoolCompare',
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
navigator.share &&
|
||||||
|
(!navigator.canShare || navigator.canShare(shareData))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
track('compare_shared', { method: 'native', school_count: count });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as DOMException)?.name === 'AbortError') return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(url);
|
||||||
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||||
setShareConfirm(true);
|
setShareConfirm(true);
|
||||||
setTimeout(() => setShareConfirm(false), 2000);
|
setTimeout(() => setShareConfirm(false), 2000);
|
||||||
} catch { /* fallback: do nothing */ }
|
} catch {
|
||||||
|
/* fallback: do nothing */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPrimary = comparePhase === 'primary';
|
const isPrimary = comparePhase === 'primary';
|
||||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
|
||||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
|
||||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
|
||||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||||
|
|
||||||
// Get metric definition
|
|
||||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
|
||||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
|
||||||
|
|
||||||
// No schools selected
|
|
||||||
if (selectedSchools.length === 0) {
|
if (selectedSchools.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Add schools to your comparison basket to see side-by-side performance data
|
Add schools to your comparison basket to see them side by side — inspection results,
|
||||||
|
academics, admissions and community.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -186,39 +282,46 @@ export function ComparisonView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filtered comparison data for active phase
|
// Build filtered comparison data for the active phase
|
||||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||||
if (comparisonData) {
|
if (comparisonData) {
|
||||||
activeSchools.forEach(s => {
|
activeSchools.forEach((s) => {
|
||||||
if (comparisonData[s.urn]) {
|
if (comparisonData[s.urn]) {
|
||||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const hasData = Object.keys(activeComparisonData).length > 0;
|
||||||
// Get years for table
|
|
||||||
const years =
|
|
||||||
Object.keys(activeComparisonData).length > 0
|
|
||||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<div>
|
<div>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
|
{selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
|
||||||
|
— each number anchored against the England average so you can tell at a glance
|
||||||
|
what's typical and what stands out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className={styles.headerActions}>
|
||||||
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
||||||
+ Add School
|
+ Add School
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||||
|
<polyline points="16 6 12 2 8 6" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
{shareConfirm ? 'Copied!' : 'Share'}
|
{shareConfirm ? 'Copied!' : 'Share'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,20 +329,22 @@ export function ComparisonView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Phase Tabs */}
|
{/* Phase Tabs */}
|
||||||
<div className={styles.phaseTabs}>
|
{secondarySchools.length > 0 && primarySchools.length > 0 && (
|
||||||
<button
|
<div className={styles.phaseTabs}>
|
||||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
<button
|
||||||
onClick={() => handlePhaseChange('primary')}
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
>
|
onClick={() => handlePhaseChange('primary')}
|
||||||
Primary ({primarySchools.length})
|
>
|
||||||
</button>
|
Primary ({primarySchools.length})
|
||||||
<button
|
</button>
|
||||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
<button
|
||||||
onClick={() => handlePhaseChange('secondary')}
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
>
|
onClick={() => handlePhaseChange('secondary')}
|
||||||
Secondary ({secondarySchools.length})
|
>
|
||||||
</button>
|
Secondary ({secondarySchools.length})
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSchools.length === 0 ? (
|
{activeSchools.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -252,182 +357,104 @@ export function ComparisonView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Metric Selector */}
|
{/* Sticky school bar — column identity while scrolling. On desktop
|
||||||
<section className={styles.metricSelector}>
|
it shares the sections' grid template (via --school-count) so
|
||||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
each chip sits exactly over the column it labels. */}
|
||||||
Select Metric:
|
<div
|
||||||
</label>
|
className={styles.schoolBar}
|
||||||
<select
|
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||||||
id="metric-select"
|
aria-label="Schools in this comparison"
|
||||||
value={selectedMetric}
|
>
|
||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
|
||||||
className={styles.metricSelect}
|
<div className={styles.barCaption}>
|
||||||
>
|
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||||||
{optgroups.map(({ label, category }) => {
|
<span className={styles.barCaptionCount}>
|
||||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
{activeSchools.length} {comparePhase} school
|
||||||
if (groupMetrics.length === 0) return null;
|
{activeSchools.length === 1 ? '' : 's'}
|
||||||
return (
|
</span>
|
||||||
<optgroup key={category} label={label}>
|
|
||||||
{groupMetrics.map((metric) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
{currentMetricDef?.description && (
|
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Progress score explanation */}
|
|
||||||
{selectedMetric.includes('progress') && (
|
|
||||||
<p className={styles.progressNote}>
|
|
||||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* School Cards */}
|
|
||||||
<section className={styles.schoolsSection}>
|
|
||||||
<div className={styles.schoolsGrid}>
|
|
||||||
{activeSchools.map((school, index) => (
|
|
||||||
<div
|
|
||||||
key={school.urn}
|
|
||||||
className={styles.schoolCard}
|
|
||||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveSchool(school.urn)}
|
|
||||||
className={styles.removeButton}
|
|
||||||
aria-label={`Remove ${school.school_name}`}
|
|
||||||
title="Remove from comparison"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<h2 className={styles.schoolName}>
|
|
||||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
|
||||||
</h2>
|
|
||||||
<div className={styles.schoolMeta}>
|
|
||||||
{school.local_authority && (
|
|
||||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
|
||||||
)}
|
|
||||||
{school.school_type && (
|
|
||||||
<span className={styles.metaItem}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest metric value */}
|
|
||||||
{activeComparisonData[school.urn] && (
|
|
||||||
<div className={styles.latestValue}>
|
|
||||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
|
||||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
|
||||||
if (yearlyData.length === 0) return '-';
|
|
||||||
|
|
||||||
const latestData = yearlyData[yearlyData.length - 1];
|
|
||||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
|
||||||
|
|
||||||
if (value === null || value === undefined) return '-';
|
|
||||||
|
|
||||||
if (selectedMetric.includes('progress')) {
|
|
||||||
return formatProgress(value as number);
|
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
|
||||||
return formatPercentage(value as number);
|
|
||||||
} else {
|
|
||||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{activeSchools.map((school, index) => (
|
||||||
|
<div
|
||||||
{/* Comparison Chart */}
|
key={school.urn}
|
||||||
{Object.keys(activeComparisonData).length > 0 ? (
|
className={styles.schoolChip}
|
||||||
<section className={styles.chartSection}>
|
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
>
|
||||||
<div className={styles.chartContainer}>
|
<span
|
||||||
<ComparisonChart
|
className={styles.chipDot}
|
||||||
comparisonData={activeComparisonData}
|
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
metric={selectedMetric}
|
aria-hidden="true"
|
||||||
metricLabel={metricLabel}
|
|
||||||
/>
|
/>
|
||||||
|
<span className={styles.chipText}>
|
||||||
|
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
|
||||||
|
<span className={styles.chipNameFull}>{school.school_name}</span>
|
||||||
|
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||||||
|
</a>
|
||||||
|
<span className={styles.chipMeta}>
|
||||||
|
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSchool(school.urn)}
|
||||||
|
className={styles.chipRemove}
|
||||||
|
aria-label={`Remove ${school.school_name}`}
|
||||||
|
title="Remove from comparison"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
))}
|
||||||
) : activeSchools.length > 0 ? (
|
</div>
|
||||||
<section className={styles.chartSection}>
|
|
||||||
<LoadingSkeleton type="list" />
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Comparison Table */}
|
{hasData && (
|
||||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
<>
|
||||||
<section className={styles.tableSection}>
|
<CompareAtAGlance
|
||||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
schools={activeSchools}
|
||||||
<div className={styles.tableWrapper}>
|
data={activeComparisonData}
|
||||||
<table className={styles.comparisonTable}>
|
nationalAverages={nationalAverages}
|
||||||
<thead>
|
benchmarks={benchmarks}
|
||||||
<tr>
|
isSecondary={!isPrimary}
|
||||||
<th>Year</th>
|
/>
|
||||||
{activeSchools.map((school) => (
|
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||||||
<th key={school.urn}>{school.school_name}</th>
|
<CompareAcademics
|
||||||
))}
|
schools={activeSchools}
|
||||||
</tr>
|
data={activeComparisonData}
|
||||||
</thead>
|
nationalAverages={nationalAverages}
|
||||||
<tbody>
|
benchmarks={benchmarks}
|
||||||
{years.map((year) => (
|
isSecondary={!isPrimary}
|
||||||
<tr key={year}>
|
/>
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
<CompareAdmissions
|
||||||
{activeSchools.map((school) => {
|
schools={activeSchools}
|
||||||
const schoolData = activeComparisonData[school.urn];
|
data={activeComparisonData}
|
||||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
isSecondary={!isPrimary}
|
||||||
|
/>
|
||||||
|
<CompareCommunity
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
benchmarks={benchmarks}
|
||||||
|
isSecondary={!isPrimary}
|
||||||
|
/>
|
||||||
|
<TrendsExplorer
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
metrics={metrics}
|
||||||
|
metric={selectedMetric}
|
||||||
|
onMetricChange={setSelectedMetric}
|
||||||
|
isPrimaryPhase={isPrimary}
|
||||||
|
nationalAverages={nationalAverages}
|
||||||
|
/>
|
||||||
|
|
||||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
<p className={styles.footnote}>
|
||||||
if (!yearData) return <td key={school.urn}>-</td>;
|
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
|
||||||
|
outcomes, DfE school admissions data, school census. England averages for test
|
||||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
results are official DfE figures; other benchmarks are state-school averages
|
||||||
|
computed from our dataset. Following DfE practice, figures based on 5 or fewer
|
||||||
if (value === null || value === undefined) {
|
pupils are suppressed and shown as "no data".
|
||||||
return <td key={school.urn}>-</td>;
|
</p>
|
||||||
}
|
</>
|
||||||
|
|
||||||
let displayValue: string;
|
|
||||||
if (selectedMetric.includes('progress')) {
|
|
||||||
displayValue = formatProgress(value as number);
|
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
|
||||||
displayValue = formatPercentage(value as number);
|
|
||||||
} else {
|
|
||||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <td key={school.urn}>{displayValue}</td>;
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* School Search Modal */}
|
|
||||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
.bad {
|
.bad {
|
||||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neutral {
|
.neutral {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.row {
|
||||||
|
margin: 1.1rem 0 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headNote {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
position: relative;
|
||||||
|
height: 34px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 15px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
width: 2px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: -14px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: 27px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointLabelAbove {
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.row {
|
||||||
|
margin: 0.9rem 0 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointLabel {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* DotStrip — the compare screen's signature element: one measure per strip,
|
||||||
|
* every school's dot on a shared track, anchored by a grey England-average
|
||||||
|
* tick so "right of the tick = above average" needs no domain knowledge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { stripPositions } from '@/lib/compareLogic';
|
||||||
|
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
|
||||||
|
import styles from './DotStrip.module.css';
|
||||||
|
|
||||||
|
export interface DotStripProps {
|
||||||
|
label: string;
|
||||||
|
/** One value per school; index = the school's chart-colour index. */
|
||||||
|
values: Array<number | null>;
|
||||||
|
schoolNames: string[];
|
||||||
|
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
|
||||||
|
* benchmark isn't available — the caller should say why in `headNote`. */
|
||||||
|
anchor?: { value: number; label: string } | null;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
unit?: string;
|
||||||
|
/** Tooltip on the measure label (plain-English definition). */
|
||||||
|
tip?: string;
|
||||||
|
/** Small note on the right of the header row (e.g. the tick legend). */
|
||||||
|
headNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DotStrip({
|
||||||
|
label,
|
||||||
|
values,
|
||||||
|
schoolNames,
|
||||||
|
anchor = null,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
unit = '%',
|
||||||
|
tip,
|
||||||
|
headNote,
|
||||||
|
}: DotStripProps) {
|
||||||
|
const points = stripPositions(values, min, max);
|
||||||
|
const span = max - min;
|
||||||
|
const anchorPos =
|
||||||
|
anchor != null
|
||||||
|
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ariaParts = [
|
||||||
|
anchor ? `${anchor.label}` : null,
|
||||||
|
...points.map(
|
||||||
|
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<span className={styles.title} title={tip}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{headNote && <span className={styles.headNote}>{headNote}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
|
||||||
|
<div className={styles.track} />
|
||||||
|
{anchorPos != null && anchor && (
|
||||||
|
<>
|
||||||
|
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
|
||||||
|
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
|
||||||
|
{anchor.label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{points.map((p) => (
|
||||||
|
<span key={p.schoolIndex}>
|
||||||
|
<span
|
||||||
|
className={styles.point}
|
||||||
|
style={{
|
||||||
|
left: `${p.pos}%`,
|
||||||
|
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
|
||||||
|
}}
|
||||||
|
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${p.pos}%`,
|
||||||
|
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.value}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Server component: pure markup, no client state.
|
||||||
|
|
||||||
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
|
interface EditorialSectionProps {
|
||||||
|
totalSchools: number | null;
|
||||||
|
localAuthorityCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className={styles.editorial}>
|
||||||
|
<div className={styles.editorialGrid}>
|
||||||
|
<div className={styles.editorialText}>
|
||||||
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
|
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||||||
|
<p>
|
||||||
|
School performance data in England is rich but fragmented. The Department for Education publishes
|
||||||
|
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||||||
|
demographics — each in its own table, each with its own jargon.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||||
|
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||||
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
|
40-page inspection report.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factbox}>
|
||||||
|
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Schools covered</span>
|
||||||
|
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Local authorities</span>
|
||||||
|
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Phases</span>
|
||||||
|
<span className={styles.factVal}>Primary & Secondary</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Latest results year</span>
|
||||||
|
<span className={styles.factVal}>2024/25</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Historical data</span>
|
||||||
|
<span className={styles.factVal}>2016–2025</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Metrics per school</span>
|
||||||
|
<span className={styles.factVal}>40+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,91 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchHint {
|
||||||
|
margin: 0.875rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary, #5a554d);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchHint strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.searchHint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.375rem;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, transform 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:hover:not(:disabled) {
|
||||||
|
background: #235f5f;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeSpinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: nearMeSpin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nearMeSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.geoError {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.nearMeBtn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -261,6 +346,21 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When filters are applied, promote the toggle to a coral pill so users
|
||||||
|
can see at a glance that the result list is being narrowed. */
|
||||||
|
.advancedToggleActive {
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedToggleActive:hover {
|
||||||
|
border-color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
.chevronDown,
|
.chevronDown,
|
||||||
.chevronUp {
|
.chevronUp {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from "@/lib/utils";
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import type { Filters, ResultFilters } from "@/lib/types";
|
import type { Filters, ResultFilters } from "@/lib/types";
|
||||||
import styles from "./FilterBar.module.css";
|
import styles from "./FilterBar.module.css";
|
||||||
|
|
||||||
@@ -10,9 +11,21 @@ interface FilterBarProps {
|
|||||||
filters: Filters;
|
filters: Filters;
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
resultFilters?: ResultFilters;
|
resultFilters?: ResultFilters;
|
||||||
|
// Geolocation "use my location" affordance, shown beside the hero search box.
|
||||||
|
// The state and handler live in HomeView (which owns the geolocation flow).
|
||||||
|
onNearMe?: () => void;
|
||||||
|
geoState?: "idle" | "requesting" | "error";
|
||||||
|
geoError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
export function FilterBar({
|
||||||
|
filters,
|
||||||
|
isHero,
|
||||||
|
resultFilters,
|
||||||
|
onNearMe,
|
||||||
|
geoState = "idle",
|
||||||
|
geoError,
|
||||||
|
}: FilterBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -93,14 +106,36 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidPostcode(omniValue)) {
|
const isPostcode = isValidPostcode(omniValue);
|
||||||
|
const cleaned = omniValue.trim();
|
||||||
|
|
||||||
|
// Build a comma-separated active-filter list so a single search event
|
||||||
|
// captures the whole intent (vs firing N events as filters are picked).
|
||||||
|
const filters_active = [
|
||||||
|
currentPhase && `phase=${currentPhase}`,
|
||||||
|
currentLA && `la=${currentLA}`,
|
||||||
|
currentType && `type=${currentType}`,
|
||||||
|
currentGender && `gender=${currentGender}`,
|
||||||
|
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
|
||||||
|
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
|
||||||
|
].filter(Boolean).join(',');
|
||||||
|
|
||||||
|
track('search_submitted', {
|
||||||
|
query: isPostcode ? cleaned.toUpperCase() : cleaned.toLowerCase(),
|
||||||
|
via: 'input',
|
||||||
|
has_postcode: isPostcode,
|
||||||
|
filters_active,
|
||||||
|
filters_count: filters_active ? filters_active.split(',').length : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPostcode) {
|
||||||
updateURL({
|
updateURL({
|
||||||
postcode: omniValue.trim().toUpperCase(),
|
postcode: cleaned.toUpperCase(),
|
||||||
radius: currentRadius || "1",
|
radius: currentRadius || "1",
|
||||||
search: "",
|
search: "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
|
updateURL({ search: cleaned, postcode: "", radius: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{isHero && (
|
||||||
|
<>
|
||||||
|
<p className={styles.searchHint}>
|
||||||
|
Search by <strong>school name</strong> — or use your{" "}
|
||||||
|
<strong>postcode</strong> for the nearest schools.
|
||||||
|
</p>
|
||||||
|
{onNearMe && (
|
||||||
|
<div className={styles.nearMeRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.nearMeBtn}
|
||||||
|
onClick={onNearMe}
|
||||||
|
disabled={geoState === "requesting"}
|
||||||
|
>
|
||||||
|
{geoState === "requesting" ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.nearMeSpinner} aria-hidden="true" />
|
||||||
|
Locating you…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
|
||||||
|
<circle cx="12" cy="9" r="2.5" />
|
||||||
|
</svg>
|
||||||
|
Use my location
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{geoError && (
|
||||||
|
<p className={styles.geoError} role="alert">
|
||||||
|
{geoError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
@@ -199,10 +280,11 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.advancedToggle}
|
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
aria-expanded={filtersOpen}
|
||||||
>
|
>
|
||||||
Advanced
|
{hasActiveDropdownFilters ? 'Filters' : 'Advanced'}
|
||||||
{hasActiveDropdownFilters
|
{hasActiveDropdownFilters
|
||||||
? ` (${activeDropdownFilters.length})`
|
? ` (${activeDropdownFilters.length})`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -286,8 +368,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">With or without sixth form</option>
|
<option value="">With or without sixth form</option>
|
||||||
<option value="yes">With sixth form (11-18)</option>
|
<option value="yes">With sixth form</option>
|
||||||
<option value="no">Without sixth form (11-16)</option>
|
<option value="no">Without sixth form</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{admissionsPolicyOptions.length > 0 && (
|
{admissionsPolicyOptions.length > 0 && (
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 1.5fr 1fr 1fr;
|
||||||
gap: 3rem;
|
gap: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user