Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0100cce69 | ||
|
|
23b4e1c453 | ||
|
|
deeef23131 | ||
|
|
4ece55b031 | ||
|
|
515494dbf0 | ||
|
|
5772c54ccd | ||
|
|
c62ba0ca25 | ||
|
|
b5a63e82d4 | ||
|
|
f5de745a8b | ||
|
|
d0895c71df | ||
|
|
df0bf1c4d6 | ||
|
|
4a52735356 | ||
|
|
f2ed49c0a1 | ||
|
|
f24b8044f8 | ||
|
|
29f79fe948 | ||
|
|
0294038fd3 | ||
|
|
a1fa4fe874 | ||
|
|
192173e515 |
@@ -1,12 +1,9 @@
|
|||||||
name: Build and Push Docker Images
|
name: Deploy (staging -> E2E gate -> production)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: privaterepo.sitaru.org
|
REGISTRY: privaterepo.sitaru.org
|
||||||
@@ -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,110 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=sha
|
||||||
type=ref,event=pr
|
type=raw,value=staging
|
||||||
type=sha,prefix=pipeline-
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Pipeline Docker image
|
- name: Build and push Pipeline Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./pipeline
|
context: ./pipeline
|
||||||
file: ./pipeline/Dockerfile
|
file: ./pipeline/Dockerfile
|
||||||
push: ${{ gitea.event_name != 'pull_request' }}
|
push: true
|
||||||
tags: ${{ steps.meta-pipeline.outputs.tags }}
|
tags: ${{ steps.meta-pipeline.outputs.tags }}
|
||||||
labels: ${{ steps.meta-pipeline.outputs.labels }}
|
labels: ${{ steps.meta-pipeline.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|
||||||
trigger-deployment:
|
deploy-staging:
|
||||||
name: Trigger Portainer Update
|
name: Deploy to Staging
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-backend, build-frontend, build-pipeline]
|
needs: [build-backend, build-frontend, build-pipeline]
|
||||||
if: gitea.event_name != 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Portainer stack update
|
- name: Trigger staging stack update
|
||||||
|
run: curl -fsSk -X POST "${{ secrets.PORTAINER_STAGING_WEBHOOK }}"
|
||||||
|
|
||||||
|
- name: Wait for staging to become healthy
|
||||||
run: |
|
run: |
|
||||||
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"
|
echo "Polling ${STAGING_BASE_URL} for up to 5 minutes..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS -o /dev/null --max-time 10 "${STAGING_BASE_URL}/"; then
|
||||||
|
echo "Staging is up (attempt $i)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "Staging did not become healthy in time" >&2
|
||||||
|
exit 1
|
||||||
|
env:
|
||||||
|
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||||
|
|
||||||
|
e2e-staging:
|
||||||
|
name: E2E Journeys against Staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [deploy-staging]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
working-directory: e2e
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run E2E journeys
|
||||||
|
working-directory: e2e
|
||||||
|
run: npx playwright test
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||||
|
|
||||||
|
promote-prod:
|
||||||
|
name: Promote to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [e2e-staging]
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Retag verified images as prod
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
|
||||||
|
for IMAGE in \
|
||||||
|
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
|
||||||
|
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
|
||||||
|
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
|
||||||
|
# Keep a rollback pointer before moving :prod
|
||||||
|
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
|
||||||
|
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
|
||||||
|
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Trigger production stack update
|
||||||
|
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
|
||||||
|
|
||||||
|
- name: Wait for production to become healthy
|
||||||
|
run: |
|
||||||
|
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
|
||||||
|
echo "Production is up (attempt $i)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "Production did not become healthy in time" >&2
|
||||||
|
exit 1
|
||||||
|
env:
|
||||||
|
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: privaterepo.sitaru.org
|
||||||
|
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||||
|
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||||
|
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend-checks:
|
||||||
|
name: Frontend Typecheck + Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: nextjs-app/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: nextjs-app
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
working-directory: nextjs-app
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
working-directory: nextjs-app
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
backend-checks:
|
||||||
|
name: Backend Smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Import smoke test
|
||||||
|
run: python -c "from backend.app import app; print('backend imports OK')"
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
name: Build Backend (no push)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."docker.io"]
|
||||||
|
mirrors = ["10.0.1.224:6000"]
|
||||||
|
[registry."10.0.1.224:6000"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Backend Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
name: Build Frontend (no push)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."docker.io"]
|
||||||
|
mirrors = ["10.0.1.224:6000"]
|
||||||
|
[registry."10.0.1.224:6000"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Frontend Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./nextjs-app
|
||||||
|
file: ./nextjs-app/Dockerfile
|
||||||
|
push: false
|
||||||
|
build-args: |
|
||||||
|
FASTAPI_URL=http://backend:80/api
|
||||||
|
|
||||||
|
build-pipeline:
|
||||||
|
name: Build Pipeline (no push)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."docker.io"]
|
||||||
|
mirrors = ["10.0.1.224:6000"]
|
||||||
|
[registry."10.0.1.224:6000"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Pipeline Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./pipeline
|
||||||
|
file: ./pipeline/Dockerfile
|
||||||
|
push: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
|
||||||
|
|
||||||
|
ai-review:
|
||||||
|
name: AI Code Review (Claude)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Claude Code
|
||||||
|
run: npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
- name: Review PR diff with Claude Code
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# Auto-provided per-run token from Gitea Actions (repo-scoped).
|
||||||
|
# GITHUB_TOKEN is the documented name; GITEA_TOKEN is its alias.
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||||
|
BASE_REF: ${{ gitea.event.pull_request.base.ref }}
|
||||||
|
run: python scripts/ci/ai_review.py
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: PR Comment Agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ai-fixup:
|
||||||
|
name: Claude Fix-up (@claude comment)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Only PR comments, only from the repo owner, only when addressed to @claude.
|
||||||
|
# The owner guard matters: the job pushes code to the PR branch.
|
||||||
|
if: >-
|
||||||
|
gitea.event.issue.pull_request &&
|
||||||
|
startsWith(gitea.event.comment.body, '@claude') &&
|
||||||
|
gitea.event.comment.user.login == gitea.repository_owner
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Claude Code
|
||||||
|
run: npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
- name: Apply the requested fix-up
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
PR_NUMBER: ${{ gitea.event.issue.number }}
|
||||||
|
COMMENT_BODY: ${{ gitea.event.comment.body }}
|
||||||
|
# Claude Code runs as root inside the runner container; this flag
|
||||||
|
# acknowledges the container *is* the sandbox.
|
||||||
|
IS_SANDBOX: "1"
|
||||||
|
run: python scripts/ci/ai_fixup.py
|
||||||
@@ -105,8 +105,22 @@ This starts:
|
|||||||
- `GET /api/metrics` - Metric definitions (single source of truth)
|
- `GET /api/metrics` - Metric definitions (single source of truth)
|
||||||
- `GET /api/data-info` - Database stats
|
- `GET /api/data-info` - Database stats
|
||||||
|
|
||||||
|
## SDLC
|
||||||
|
|
||||||
|
Full details in `docs/DEPLOY.md`. The short version:
|
||||||
|
|
||||||
|
- **Never push to `main` directly.** Work on a feature branch and open a PR;
|
||||||
|
branch protection requires the PR checks (typecheck, tests, builds, AI review)
|
||||||
|
to pass before merge.
|
||||||
|
- Merging to `main` deploys automatically: images are built once, deployed to
|
||||||
|
the **staging** Portainer stack, verified by the Playwright journeys in
|
||||||
|
`e2e/`, and only then retagged `:prod` and rolled out to production.
|
||||||
|
- If you change user-facing behaviour, update or extend the `e2e/` journey
|
||||||
|
tests in the same PR — they are the promotion gate.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
|
- Added staging environment + automated staging→prod pipeline (Gitea Actions)
|
||||||
- Migrated from CSV file storage to PostgreSQL database
|
- Migrated from CSV file storage to PostgreSQL database
|
||||||
- Added location-based search using postcode geocoding
|
- Added location-based search using postcode geocoding
|
||||||
- Added local authority filter to rankings
|
- Added local authority filter to rankings
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# SDLC & Deployment Pipeline
|
||||||
|
|
||||||
|
SchoolCompare uses a fully automated staging → production pipeline on Gitea
|
||||||
|
Actions. AI writes the code on feature branches; the pipeline verifies every
|
||||||
|
change on a staging environment before promoting the exact same images to
|
||||||
|
production. Human input is directional only: feature requests, PR review if
|
||||||
|
desired, and intervention when a gate fails.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
```
|
||||||
|
feature branch (AI-authored)
|
||||||
|
│ PR to main
|
||||||
|
▼
|
||||||
|
PR checks (.gitea/workflows/pr-checks.yml)
|
||||||
|
typecheck + unit tests + backend smoke + image builds (no push)
|
||||||
|
+ Claude code review posted as a PR comment (severe findings fail the check)
|
||||||
|
│ merge (branch protection requires green checks)
|
||||||
|
▼
|
||||||
|
Deploy pipeline (.gitea/workflows/deploy.yml)
|
||||||
|
1. build & push images → tags sha-<sha>, staging
|
||||||
|
2. staging Portainer webhook → wait for staging health
|
||||||
|
3. Playwright E2E journeys against staging
|
||||||
|
4. retag sha-<sha> → :prod (same bytes — build once, promote the image)
|
||||||
|
previous :prod saved as :prod-previous
|
||||||
|
5. prod Portainer webhook → wait for prod health
|
||||||
|
```
|
||||||
|
|
||||||
|
Key principle: **build once, promote the exact image**. Production pins `:prod`,
|
||||||
|
which only moves after the E2E gate passes on staging. Nothing tags `:latest`
|
||||||
|
anymore.
|
||||||
|
|
||||||
|
## Branch & PR workflow
|
||||||
|
|
||||||
|
- `main` is protected: no direct pushes, PRs require green status checks.
|
||||||
|
- All work (human or AI) happens on feature branches → PR to `main`.
|
||||||
|
- Merging to `main` **is** the release action. If staging or the E2E gate
|
||||||
|
fails, production is untouched.
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
| | Production | Staging |
|
||||||
|
|---|---|---|
|
||||||
|
| Portainer stack file | `docker-compose.portainer.yml` | `docker-compose.portainer.staging.yml` |
|
||||||
|
| Image tag | `:prod` | `:staging` |
|
||||||
|
| Container prefix | `sc_` / `schoolcompare_` | `sc_staging_` |
|
||||||
|
| Frontend macvlan IP | 10.0.1.150 | `STAGING_FRONTEND_IP` (default 10.0.1.151) |
|
||||||
|
| Postgres macvlan IP | 10.0.1.189 | `STAGING_DB_IP` (default 10.0.1.190) |
|
||||||
|
| Airflow UI port | 8080 | 8081 |
|
||||||
|
| Volumes | stack-prefixed | stack-prefixed (fully isolated) |
|
||||||
|
|
||||||
|
Staging gets `:staging` images on every merge to main — even ones that later
|
||||||
|
fail the E2E gate. That's the point: staging absorbs the risk.
|
||||||
|
|
||||||
|
## Gitea repository secrets
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (already set) |
|
||||||
|
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription auth for the PR review — generate with `claude setup-token` on your machine |
|
||||||
|
| `PORTAINER_STAGING_WEBHOOK` | staging stack redeploy webhook URL |
|
||||||
|
| `PORTAINER_PROD_WEBHOOK` | production stack redeploy webhook URL |
|
||||||
|
| `STAGING_BASE_URL` | e.g. `http://10.0.1.151:3000` — health poll + E2E target |
|
||||||
|
| `PROD_BASE_URL` | e.g. `http://10.0.1.150:3000` — post-promotion health poll |
|
||||||
|
|
||||||
|
## One-time setup checklist
|
||||||
|
|
||||||
|
1. **Create the staging stack** in Portainer from
|
||||||
|
`docker-compose.portainer.staging.yml` (stack name e.g.
|
||||||
|
`schoolcompare-staging`). Set the same environment variables as prod plus
|
||||||
|
`STAGING_DB_IP` / `STAGING_FRONTEND_IP` if the defaults clash.
|
||||||
|
2. **Enable webhooks** on both stacks (Portainer → Stack → Webhook) and store
|
||||||
|
the URLs as `PORTAINER_STAGING_WEBHOOK` / `PORTAINER_PROD_WEBHOOK`. Remove
|
||||||
|
the old hardcoded webhook usage (now gone from the workflows).
|
||||||
|
3. **Add the remaining secrets** listed above in Gitea → repo → Settings →
|
||||||
|
Actions → Secrets.
|
||||||
|
4. **Protect `main`** in Gitea → Settings → Branches: require PRs, require the
|
||||||
|
pr-checks status checks (frontend, backend, builds, ai-review) to pass.
|
||||||
|
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
||||||
|
itself from source, exercising the pipeline image end-to-end):
|
||||||
|
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
|
||||||
|
`school_data_daily`, `school_data_monthly_ofsted`,
|
||||||
|
`school_data_monthly_parent_view`, then the manual-schedule
|
||||||
|
`school_data_annual_ees` and `school_data_annual_idaci`.
|
||||||
|
- First runs download from government sources (GIAS, Ofsted, EES, IDACI),
|
||||||
|
run dbt, and sync Typesense — expect the initial backfill to take a while.
|
||||||
|
- The scheduled DAGs then keep staging fresh exactly like prod.
|
||||||
|
6. **Switch the prod stack to `:prod` tags** — the repo's
|
||||||
|
`docker-compose.portainer.yml` is already updated; redeploy the prod stack
|
||||||
|
from it. Until the first pipeline run promotes an image, tag the current
|
||||||
|
images manually: `docker buildx imagetools create -t <image>:prod <image>:latest`
|
||||||
|
for each of the three images.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Every promotion first re-points `:prod-previous` at the outgoing `:prod`.
|
||||||
|
To roll back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for img in backend frontend pipeline; do
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t privaterepo.sitaru.org/tudor/school_compare-$img:prod \
|
||||||
|
privaterepo.sitaru.org/tudor/school_compare-$img:prod-previous
|
||||||
|
done
|
||||||
|
curl -fsSk -X POST "$PORTAINER_PROD_WEBHOOK"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or promote any older build directly: `imagetools create -t <image>:prod <image>:sha-<shortsha>`.
|
||||||
|
|
||||||
|
## E2E suite
|
||||||
|
|
||||||
|
Lives in `e2e/` (own package — CI installs it without the app's node_modules).
|
||||||
|
Journeys: home + name search, postcode search, school detail, two-school
|
||||||
|
comparison, rankings table. Run locally against any environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e && npm ci
|
||||||
|
BASE_URL=http://10.0.1.151:3000 npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests assert data invariants (results exist, charts render), not exact
|
||||||
|
numbers, so scheduled data refreshes don't break the gate.
|
||||||
|
|
||||||
|
## AI code review
|
||||||
|
|
||||||
|
`scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
|
||||||
|
(`claude -p`, authenticated with the subscription OAuth token — no API
|
||||||
|
billing), posts the structured findings as a PR comment using the per-run
|
||||||
|
token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
|
||||||
|
needed), and fails the check only when a finding is rated
|
||||||
|
**severe** (would break prod, leak data, or corrupt data). Minor findings are
|
||||||
|
informational and never block a merge.
|
||||||
|
|
||||||
|
## Comment-triggered fix-ups (@claude)
|
||||||
|
|
||||||
|
Comment `@claude <instruction>` on any PR and `.gitea/workflows/pr-comment.yml`
|
||||||
|
runs `scripts/ci/ai_fixup.py`: it checks out the PR branch, hands the
|
||||||
|
instruction to headless Claude Code (same subscription auth as the reviewer),
|
||||||
|
commits and pushes whatever changed, and replies on the PR with a summary.
|
||||||
|
The push re-runs the PR checks automatically.
|
||||||
|
|
||||||
|
Guard rails:
|
||||||
|
- Only comments from the **repo owner** trigger it (the job pushes code).
|
||||||
|
- Only comments starting with `@claude` — the bot's own replies never re-trigger.
|
||||||
|
- Each comment is one full agentic session on the Claude subscription; batch
|
||||||
|
related asks into one comment rather than several small ones.
|
||||||
|
|
||||||
|
Caveat: if the checks don't re-run after the bot's push, Gitea is suppressing
|
||||||
|
workflows for pushes made with the run token — create a personal access token
|
||||||
|
secret and swap it in for the push, or re-run the checks manually.
|
||||||
@@ -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,75 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Journey tests for SchoolCompare, run against the staging environment as the
|
||||||
|
* gate before promotion to production. They assert stable data invariants
|
||||||
|
* (results exist, key UI renders) rather than exact numbers, so routine data
|
||||||
|
* refreshes don't break the pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function searchByName(page: Page, query: string) {
|
||||||
|
await page.goto('/');
|
||||||
|
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||||
|
await searchInput.fill(query);
|
||||||
|
await searchInput.press('Enter');
|
||||||
|
await page.waitForURL(/search=|postcode=/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schoolLinks(page: Page) {
|
||||||
|
return page.locator('a[href^="/school/"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('home page loads with hero search', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
|
await searchByName(page, 'primary');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
expect(await schoolLinks(page).count()).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searching by postcode returns nearby schools', async ({ page }) => {
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('school detail page renders name and performance data', async ({ page }) => {
|
||||||
|
await searchByName(page, 'primary');
|
||||||
|
const firstSchool = schoolLinks(page).first();
|
||||||
|
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
|
||||||
|
await firstSchool.click();
|
||||||
|
await page.waitForURL(/\/school\//);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
|
// The detail page renders at least one *visible* chart canvas. Plain
|
||||||
|
// .first() is wrong here: the admissions card stacks its year/trend views
|
||||||
|
// in one grid cell and keeps the inactive view's canvas visibility:hidden
|
||||||
|
// by design, and that canvas comes first in the DOM.
|
||||||
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||||
|
// Collect two school URNs from search results, then load the share URL
|
||||||
|
await searchByName(page, 'primary');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
const hrefs = await schoolLinks(page).evaluateAll((links) =>
|
||||||
|
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
||||||
|
);
|
||||||
|
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||||
|
// Both schools' detail links should render in the comparison view
|
||||||
|
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('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);
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('formatPercentage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles null values', () => {
|
it('handles null values', () => {
|
||||||
expect(formatPercentage(null)).toBe('-');
|
expect(formatPercentage(null)).toBe('N/A');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ describe('formatProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles null values', () => {
|
it('handles null values', () => {
|
||||||
expect(formatProgress(null)).toBe('-');
|
expect(formatProgress(null)).toBe('N/A');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,16 +44,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 +72,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 +106,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', () => {
|
||||||
|
|||||||
+18
-17
@@ -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 */
|
||||||
@@ -172,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) */
|
||||||
@@ -210,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 */
|
||||||
@@ -745,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 {
|
||||||
@@ -916,7 +917,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.negative {
|
.stat-value.negative {
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trend indicators */
|
/* Trend indicators */
|
||||||
@@ -930,7 +931,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-down {
|
.trend-down {
|
||||||
color: var(--accent-coral);
|
color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-stable {
|
.trend-stable {
|
||||||
@@ -1018,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 {
|
||||||
@@ -1567,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,12 @@ export default function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
<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
|
||||||
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"
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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;
|
||||||
|
|
||||||
const phase = phaseParam || 'primary';
|
const phase = phaseParam || 'primary';
|
||||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct');
|
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||||
|
|
||||||
// Fetch rankings data with error handling
|
// Fetch rankings data with error handling
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackDeadline { color: var(--accent-coral, #e07256); }
|
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
.chipTrackDot {
|
.chipTrackDot {
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepDeadline .stepDate { color: var(--accent-coral, #e07256); }
|
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
.stepTitle {
|
.stepTitle {
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--border-color, #e5dfd5);
|
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -63,13 +63,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 */
|
/* Metric Selector */
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName a:hover {
|
.schoolName a:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { SchoolSearchModal } from './SchoolSearchModal';
|
|||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
@@ -58,7 +58,7 @@ 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, addSchool, replaceSchools, isInitialized } = useComparison();
|
||||||
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -69,16 +69,22 @@ export function ComparisonView({
|
|||||||
// While true, auto-phase detection is suppressed so manual selections aren't overridden.
|
// While true, auto-phase detection is suppressed so manual selections aren't overridden.
|
||||||
const phaseLockedByUser = useRef(false);
|
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
|
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -378,7 +384,8 @@ export function ComparisonView({
|
|||||||
{activeComparisonData[school.urn] && (
|
{activeComparisonData[school.urn] && (
|
||||||
<div className={styles.latestValue}>
|
<div className={styles.latestValue}>
|
||||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
<div className={styles.latestLabel}>{metricLabel}</div>
|
||||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
|
||||||
|
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
|
||||||
|
.heroDescriptionCompact {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.heroSection {
|
.heroSection {
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
@@ -74,18 +79,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
/* Above the fold on phones, every line costs. Drop the eyebrow tag and swap
|
||||||
long descriptive paragraph — the h1 already names the product, and the
|
the long descriptive paragraph for one compact line that carries coverage
|
||||||
search input is the primary action users came to perform. */
|
("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
|
||||||
|
visitor otherwise sees only a poetic h1 and a bare box, with no reason to
|
||||||
|
trust the data (audit P1.7, feeds the 46% home exit rate). */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.heroSection {
|
.heroSection {
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.heroEyebrow,
|
.heroEyebrow,
|
||||||
.heroDescription {
|
.heroDescriptionFull {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.heroDescriptionCompact {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.heroDescription {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
}
|
}
|
||||||
@@ -121,7 +137,7 @@
|
|||||||
|
|
||||||
.viewToggleBtn.active {
|
.viewToggleBtn.active {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
box-shadow: 0 2px 4px rgba(26, 22, 18, 0.08);
|
box-shadow: 0 2px 4px rgba(26, 22, 18, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +238,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compactItemName:hover {
|
.compactItemName:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.distanceBadge {
|
.distanceBadge {
|
||||||
@@ -536,7 +552,7 @@
|
|||||||
|
|
||||||
.geoError {
|
.geoError {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -567,9 +583,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quickSearchChip:hover {
|
.quickSearchChip:hover {
|
||||||
background: var(--accent-coral);
|
background: var(--accent-coral-dark);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent-coral);
|
border-color: var(--accent-coral-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exploringRow {
|
.exploringRow {
|
||||||
@@ -712,7 +728,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hiwTitle {
|
.hiwTitle {
|
||||||
@@ -1062,7 +1078,7 @@
|
|||||||
|
|
||||||
.editorialKicker {
|
.editorialKicker {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1300,7 +1316,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackDeadline {
|
.chipTrackDeadline {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackOffer {
|
.chipTrackOffer {
|
||||||
|
|||||||
@@ -267,7 +267,15 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.heroDescription}>
|
<p className={styles.heroDescription}>
|
||||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||||
|
freshness, standing in for the hidden eyebrow too) on phones,
|
||||||
|
where every line above the fold costs. */}
|
||||||
|
<span className={styles.heroDescriptionFull}>
|
||||||
|
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||||
|
</span>
|
||||||
|
<span className={styles.heroDescriptionCompact}>
|
||||||
|
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -620,8 +628,8 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
|||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#3c8c3c' } :
|
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
|
||||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: '#b8920e' } :
|
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
||||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ function buildPopupBadge(school: School): PopupBadge {
|
|||||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||||
const colours: Record<number, string> = {
|
const colours: Record<number, string> = {
|
||||||
1: 'background:#d4f0ea;color:#2d7d7d',
|
1: 'background:#d4f0ea;color:#2d7d7d',
|
||||||
2: 'background:rgba(60,140,60,0.12);color:#3c8c3c',
|
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
|
||||||
3: 'background:#fef3cd;color:#b8920e',
|
3: 'background:#fef3cd;color:#7a6800',
|
||||||
4: 'background:#fde8e0;color:#e07256',
|
4: 'background:#fde8e0;color:#e07256',
|
||||||
};
|
};
|
||||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||||
|
|||||||
@@ -6,7 +6,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 0.85em;
|
/* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
margin: -6px 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.9em;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted, #8a7a72);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -14,8 +24,9 @@
|
|||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper:hover .icon {
|
.wrapper:hover .icon,
|
||||||
color: var(--accent-coral, #e07256);
|
.icon[aria-expanded="true"] {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@@ -39,12 +50,18 @@
|
|||||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep tooltip visible when hovering over it */
|
/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
|
||||||
.wrapper:hover .tooltip {
|
.wrapper:hover .tooltip,
|
||||||
|
.wrapper:focus-within .tooltip,
|
||||||
|
.tooltipOpen {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltipOpen {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Small arrow pointing down */
|
/* Small arrow pointing down */
|
||||||
.tooltip::after {
|
.tooltip::after {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -75,19 +92,23 @@
|
|||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flip tooltip below when near top of screen */
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.tooltip {
|
.tooltip {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On phones the icon was rendering at ~9px and the tooltip relied on
|
/* Anchor the bubble to open rightward on phones — icons follow their labels,
|
||||||
:hover, which doesn't fire on touch. Rather than build a tap-to-show
|
which start at the left edge, so centring pushed the bubble off-screen. */
|
||||||
layer with backdrop dismissal, hide the helper entirely — the metric
|
|
||||||
labels themselves carry the meaning. */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.wrapper {
|
.tooltip {
|
||||||
display: none;
|
left: -12px;
|
||||||
|
right: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
left: 16px;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||||
import styles from './MetricTooltip.module.css';
|
import styles from './MetricTooltip.module.css';
|
||||||
|
|
||||||
@@ -16,12 +17,43 @@ export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltip
|
|||||||
const tooltipPlain = plain ?? explanation?.plain;
|
const tooltipPlain = plain ?? explanation?.plain;
|
||||||
const tooltipDetail = detail ?? explanation?.detail;
|
const tooltipDetail = detail ?? explanation?.detail;
|
||||||
|
|
||||||
|
// Tap/click/keyboard toggle so the definition is reachable on touch devices
|
||||||
|
// and by keyboard, not just mouse hover (hover still works on desktop).
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const dismiss = (e: Event) => {
|
||||||
|
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', dismiss);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', dismiss);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!tooltipPlain) return null;
|
if (!tooltipPlain) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.wrapper}>
|
<span className={styles.wrapper} ref={wrapperRef}>
|
||||||
<span className={styles.icon} aria-label={tooltipLabel ?? 'More information'} role="img">ⓘ</span>
|
<button
|
||||||
<span className={styles.tooltip} role="tooltip">
|
type="button"
|
||||||
|
className={styles.icon}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</button>
|
||||||
|
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
|
||||||
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
||||||
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
||||||
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover {
|
.logo:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navLink.active {
|
.navLink.active {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabIconWrap {
|
.tabIconWrap {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chipActive {
|
.chipActive {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--accent-coral, #e07256);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -55,13 +55,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filters */
|
/* Filters */
|
||||||
@@ -187,6 +187,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table auto-layout sizes columns by the unwrapped text, so a long metric
|
||||||
|
label ("Reading, Writing & Maths Combined Higher %") widened the table
|
||||||
|
past the viewport. A block inner span is what actually caps the measured
|
||||||
|
width and forces the label onto multiple lines. */
|
||||||
|
.valueHeaderText {
|
||||||
|
display: block;
|
||||||
|
max-width: 110px;
|
||||||
|
margin: 0 auto;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.actionHeader {
|
.actionHeader {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -290,7 +302,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolLink:hover {
|
.schoolLink:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.areaCell,
|
.areaCell,
|
||||||
@@ -298,6 +310,14 @@
|
|||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||||
|
.schoolCellArea {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
.valueCell {
|
.valueCell {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -374,20 +394,26 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide less-critical columns on mobile so the metric value stays visible */
|
/* Hide less-critical columns on mobile so the metric value stays visible.
|
||||||
|
Area moves to a subline under the school name (.schoolCellArea) — with a
|
||||||
|
four-column layout the value still overflowed a 390px viewport and the
|
||||||
|
whole point of the page (the score) needed a sideways swipe to see. */
|
||||||
.typeHeader,
|
.typeHeader,
|
||||||
.typeCell,
|
.typeCell,
|
||||||
.actionHeader,
|
.actionHeader,
|
||||||
.actionCell {
|
.actionCell,
|
||||||
|
.areaHeader,
|
||||||
|
.areaCell {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolHeader {
|
.schoolCellArea {
|
||||||
min-width: 140px;
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areaHeader {
|
.schoolHeader {
|
||||||
min-width: 80px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.valueHeader,
|
.valueHeader,
|
||||||
@@ -397,7 +423,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
|
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
|
||||||
force the whole column wide; let them wrap onto 2 short lines with a
|
force the whole column wide; let them wrap onto short lines with a
|
||||||
tighter font so the value cell can stay compact. */
|
tighter font so the value cell can stay compact. */
|
||||||
.valueHeader {
|
.valueHeader {
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
@@ -406,6 +432,10 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.valueHeaderText {
|
||||||
|
max-width: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
.rankHeader {
|
.rankHeader {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function RankingsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePhaseChange = (phase: string) => {
|
const handlePhaseChange = (phase: string) => {
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct';
|
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||||
updateFilters({ phase, metric: defaultMetric });
|
updateFilters({ phase, metric: defaultMetric });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,7 +233,11 @@ export function RankingsView({
|
|||||||
<th className={styles.schoolHeader}>School</th>
|
<th className={styles.schoolHeader}>School</th>
|
||||||
<th className={styles.areaHeader}>Area</th>
|
<th className={styles.areaHeader}>Area</th>
|
||||||
<th className={styles.typeHeader}>Type</th>
|
<th className={styles.typeHeader}>Type</th>
|
||||||
<th className={styles.valueHeader}>{metricLabel}</th>
|
<th className={styles.valueHeader}>
|
||||||
|
{/* Inner block caps the column's measured width so long
|
||||||
|
labels wrap instead of widening the table off-screen. */}
|
||||||
|
<span className={styles.valueHeaderText}>{metricLabel}</span>
|
||||||
|
</th>
|
||||||
<th className={styles.actionHeader}>Action</th>
|
<th className={styles.actionHeader}>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -273,6 +277,11 @@ export function RankingsView({
|
|||||||
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
|
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
|
||||||
{ranking.school_name}
|
{ranking.school_name}
|
||||||
</a>
|
</a>
|
||||||
|
{/* On phones the Area column is hidden; the LA moves here
|
||||||
|
so the metric value fits on screen without swiping. */}
|
||||||
|
{ranking.local_authority && (
|
||||||
|
<span className={styles.schoolCellArea}>{ranking.local_authority}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
|
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
|
||||||
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
|
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
font-size: 1.0625rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
.headerHasMap .actions .btnAdd {
|
.headerHasMap .actions .btnAdd {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
@@ -87,6 +87,36 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full label by default; phones over the map get an icon-only button
|
||||||
|
(same compact treatment as the section-nav compare icon). */
|
||||||
|
.btnCompareGlyph {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.headerHasMap .actions .btnCompareLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnCompareGlyph {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnAdd,
|
||||||
|
.headerHasMap .actions .btnRemove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline "View on map ↗" trigger next to the address. */
|
/* Inline "View on map ↗" trigger next to the address. */
|
||||||
.mapLink {
|
.mapLink {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -94,7 +124,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -180,7 +210,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genderSplitGirls {
|
.genderSplitGirls {
|
||||||
color: #b45778;
|
color: #a04a68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genderSplitBoys {
|
.genderSplitBoys {
|
||||||
@@ -212,7 +242,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genderBarGirls {
|
.genderBarGirls {
|
||||||
background: #b45778;
|
background: #a04a68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genderBarBoys {
|
.genderBarBoys {
|
||||||
@@ -240,12 +270,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btnAdd {
|
.btnAdd {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnAdd:hover {
|
.btnAdd:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +323,7 @@
|
|||||||
padding: 0.3rem 0.625rem;
|
padding: 0.3rem 0.625rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -369,7 +399,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionNavLinkActive {
|
.sectionNavLinkActive {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -438,8 +468,8 @@
|
|||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
border: 1px solid var(--accent-coral, #e07256);
|
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, border-color 0.15s ease;
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
@@ -483,8 +513,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
border: 1px solid var(--accent-coral, #e07256);
|
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -492,8 +522,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionNavCompare:hover {
|
.sectionNavCompare:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
border-color: var(--accent-coral-dark, #c45a3f);
|
border-color: var(--accent-coral-darker, #9c3f26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionNavCompareIn {
|
.sectionNavCompareIn {
|
||||||
@@ -596,7 +626,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionsTick {
|
.sectionsTick {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On phones the menu becomes a bottom sheet. */
|
/* On phones the menu becomes a bottom sheet. */
|
||||||
@@ -826,7 +856,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progressNegative {
|
.progressNegative {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,12 +868,12 @@
|
|||||||
|
|
||||||
.statusWarn {
|
.statusWarn {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBad {
|
.statusBad {
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Charts Section */
|
/* Charts Section */
|
||||||
@@ -981,15 +1011,15 @@
|
|||||||
}
|
}
|
||||||
.ofstedGrade2 {
|
.ofstedGrade2 {
|
||||||
background: rgba(60, 140, 60, 0.12);
|
background: rgba(60, 140, 60, 0.12);
|
||||||
color: #3c8c3c;
|
color: #2f7a2f;
|
||||||
}
|
}
|
||||||
.ofstedGrade3 {
|
.ofstedGrade3 {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
}
|
}
|
||||||
.ofstedGrade4 {
|
.ofstedGrade4 {
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Report Card grade colours (5-level scale, lower = better) */
|
/* Report Card grade colours (5-level scale, lower = better) */
|
||||||
@@ -999,11 +1029,11 @@
|
|||||||
} /* Exceptional */
|
} /* Exceptional */
|
||||||
.rcGrade2 {
|
.rcGrade2 {
|
||||||
background: rgba(60, 140, 60, 0.12);
|
background: rgba(60, 140, 60, 0.12);
|
||||||
color: #3c8c3c;
|
color: #2f7a2f;
|
||||||
} /* Strong */
|
} /* Strong */
|
||||||
.rcGrade3 {
|
.rcGrade3 {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
} /* Expected standard */
|
} /* Expected standard */
|
||||||
.rcGrade4 {
|
.rcGrade4 {
|
||||||
background: rgba(249, 115, 22, 0.12);
|
background: rgba(249, 115, 22, 0.12);
|
||||||
@@ -1011,7 +1041,7 @@
|
|||||||
} /* Needs attention */
|
} /* Needs attention */
|
||||||
.rcGrade5 {
|
.rcGrade5 {
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
} /* Urgent improvement */
|
} /* Urgent improvement */
|
||||||
|
|
||||||
/* Safeguarding value (used inside a standard metricCard) */
|
/* Safeguarding value (used inside a standard metricCard) */
|
||||||
@@ -1031,7 +1061,7 @@
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
|
|||||||
@@ -373,8 +373,15 @@ export function SchoolDetailView({
|
|||||||
<button
|
<button
|
||||||
onClick={handleComparisonToggle}
|
onClick={handleComparisonToggle}
|
||||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||||
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||||
>
|
>
|
||||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||||
|
<span className={styles.btnCompareLabel}>
|
||||||
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||||
|
{isInComparison ? '✓' : '+'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preview: move the OSM attribution off the bottom edge, where the school
|
||||||
|
title slides up under the fade — top-left is the band's only quiet corner
|
||||||
|
(Compare floats top-right). Fullscreen keeps Leaflet's default bottom-right,
|
||||||
|
since the zoom control occupies the top-left there. */
|
||||||
|
.wrapper:not([data-fullscreen]) :global(.leaflet-bottom.leaflet-right) {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fullscreen: the Fullscreen API promotes this element to fill the viewport. */
|
/* Fullscreen: the Fullscreen API promotes this element to fill the viewport. */
|
||||||
.wrapper[data-fullscreen] {
|
.wrapper[data-fullscreen] {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|||||||
@@ -201,8 +201,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
font-size: 1.0625rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
.headerHasMap .actions .btnAdd {
|
.headerHasMap .actions .btnAdd {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
@@ -87,6 +87,36 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full label by default; phones over the map get an icon-only button
|
||||||
|
(same compact treatment as the section-nav compare icon). */
|
||||||
|
.btnCompareGlyph {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.headerHasMap .actions .btnCompareLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnCompareGlyph {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerHasMap .actions .btnAdd,
|
||||||
|
.headerHasMap .actions .btnRemove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline "View on map ↗" trigger next to the address. */
|
/* Inline "View on map ↗" trigger next to the address. */
|
||||||
.mapLink {
|
.mapLink {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -94,7 +124,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -196,12 +226,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btnAdd {
|
.btnAdd {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnAdd:hover {
|
.btnAdd:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +278,7 @@
|
|||||||
padding: 0.3rem 0.625rem;
|
padding: 0.3rem 0.625rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -292,13 +322,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabBtnActive {
|
.tabBtnActive {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabBtnActive:hover {
|
.tabBtnActive:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +456,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progressNegative {
|
.progressNegative {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +468,7 @@
|
|||||||
|
|
||||||
.statusWarn {
|
.statusWarn {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Metric table (row-based) ────────────────────────── */
|
/* ── Metric table (row-based) ────────────────────────── */
|
||||||
@@ -552,15 +582,15 @@
|
|||||||
}
|
}
|
||||||
.ofstedGrade2 {
|
.ofstedGrade2 {
|
||||||
background: rgba(60, 140, 60, 0.12);
|
background: rgba(60, 140, 60, 0.12);
|
||||||
color: #3c8c3c;
|
color: #2f7a2f;
|
||||||
}
|
}
|
||||||
.ofstedGrade3 {
|
.ofstedGrade3 {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
}
|
}
|
||||||
.ofstedGrade4 {
|
.ofstedGrade4 {
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rcGrade1 {
|
.rcGrade1 {
|
||||||
@@ -569,11 +599,11 @@
|
|||||||
}
|
}
|
||||||
.rcGrade2 {
|
.rcGrade2 {
|
||||||
background: rgba(60, 140, 60, 0.12);
|
background: rgba(60, 140, 60, 0.12);
|
||||||
color: #3c8c3c;
|
color: #2f7a2f;
|
||||||
}
|
}
|
||||||
.rcGrade3 {
|
.rcGrade3 {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
color: #b8920e;
|
color: var(--accent-gold-text, #7a6800);
|
||||||
}
|
}
|
||||||
.rcGrade4 {
|
.rcGrade4 {
|
||||||
background: rgba(249, 115, 22, 0.12);
|
background: rgba(249, 115, 22, 0.12);
|
||||||
@@ -581,7 +611,7 @@
|
|||||||
}
|
}
|
||||||
.rcGrade5 {
|
.rcGrade5 {
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.safeguardingMet {
|
.safeguardingMet {
|
||||||
@@ -601,7 +631,7 @@
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: var(--accent-coral-bg);
|
background: var(--accent-coral-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
@@ -844,7 +874,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genderBarGirls {
|
.genderBarGirls {
|
||||||
background: #b45778;
|
background: #a04a68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genderBarBoys {
|
.genderBarBoys {
|
||||||
@@ -859,7 +889,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genderSplitGirls {
|
.genderSplitGirls {
|
||||||
color: #b45778;
|
color: #a04a68;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +946,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral-dark, #b04a2e);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -292,8 +292,15 @@ export function SecondarySchoolDetailView({
|
|||||||
<button
|
<button
|
||||||
onClick={handleComparisonToggle}
|
onClick={handleComparisonToggle}
|
||||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||||
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||||
>
|
>
|
||||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||||
|
<span className={styles.btnCompareLabel}>
|
||||||
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||||
|
{isInComparison ? '✓' : '+'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deltaPositive { color: #3c8c3c; }
|
.deltaPositive { color: #2f7a2f; }
|
||||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
/* Line 4: location + distance */
|
/* Line 4: location + distance */
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface ComparisonContextType {
|
|||||||
error: any;
|
error: any;
|
||||||
addSchool: (school: School) => void;
|
addSchool: (school: School) => void;
|
||||||
removeSchool: (urn: number) => void;
|
removeSchool: (urn: number) => void;
|
||||||
|
replaceSchools: (schools: School[]) => void;
|
||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
isSelected: (urn: number) => boolean;
|
isSelected: (urn: number) => boolean;
|
||||||
canAddMore: boolean;
|
canAddMore: boolean;
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
|
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Replace the whole selection — used when a shared /compare?urns=… link
|
||||||
|
// must take precedence over whatever this visitor had stored.
|
||||||
|
const replaceSchools = useCallback((schools: School[]) => {
|
||||||
|
setSelectedSchools(schools.slice(0, MAX_SCHOOLS));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearAll = useCallback(() => {
|
const clearAll = useCallback(() => {
|
||||||
setSelectedSchools([]);
|
setSelectedSchools([]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -86,6 +92,7 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
error: null,
|
error: null,
|
||||||
addSchool,
|
addSchool,
|
||||||
removeSchool,
|
removeSchool,
|
||||||
|
replaceSchools,
|
||||||
clearAll,
|
clearAll,
|
||||||
isSelected,
|
isSelected,
|
||||||
canAddMore: selectedSchools.length < MAX_SCHOOLS,
|
canAddMore: selectedSchools.length < MAX_SCHOOLS,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function useComparison() {
|
|||||||
selectedSchools,
|
selectedSchools,
|
||||||
addSchool,
|
addSchool,
|
||||||
removeSchool,
|
removeSchool,
|
||||||
|
replaceSchools,
|
||||||
clearAll,
|
clearAll,
|
||||||
isSelected,
|
isSelected,
|
||||||
canAddMore,
|
canAddMore,
|
||||||
@@ -39,6 +40,7 @@ export function useComparison() {
|
|||||||
error,
|
error,
|
||||||
addSchool,
|
addSchool,
|
||||||
removeSchool,
|
removeSchool,
|
||||||
|
replaceSchools,
|
||||||
clearAll,
|
clearAll,
|
||||||
isSelected,
|
isSelected,
|
||||||
canAddMore,
|
canAddMore,
|
||||||
|
|||||||
@@ -275,6 +275,27 @@ export function getChartColor(index: number): string {
|
|||||||
return CHART_COLORS[index % CHART_COLORS.length];
|
return CHART_COLORS[index % CHART_COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darker counterparts of CHART_COLORS for use as TEXT on the site's light
|
||||||
|
* backgrounds — the raw series colours fail WCAG AA (e.g. 1.9:1 on cream).
|
||||||
|
* Same hue per index so text still keys to its chart line. All ≥4.5:1 on
|
||||||
|
* #f3ede4 and lighter.
|
||||||
|
*/
|
||||||
|
export const CHART_TEXT_COLORS = [
|
||||||
|
'#256868', // teal
|
||||||
|
'#c2255c', // pink/red
|
||||||
|
'#2069ad', // blue
|
||||||
|
'#8a5a00', // yellow → ochre
|
||||||
|
'#6d43c8', // purple
|
||||||
|
'#8f4e00', // orange → brown
|
||||||
|
'#5f5952', // grey
|
||||||
|
'#a300a3', // magenta
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getChartTextColor(index: number): string {
|
||||||
|
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert RGB color to RGBA with opacity
|
* Convert RGB color to RGBA with opacity
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage"
|
"test:coverage": "jest --coverage"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Comment-triggered PR fix-ups, powered by Claude Code.
|
||||||
|
|
||||||
|
Runs when a maintainer comments `@claude <instruction>` on a pull request.
|
||||||
|
Checks out the PR head branch, hands the instruction to headless Claude Code
|
||||||
|
(subscription OAuth auth — no API billing), commits and pushes whatever it
|
||||||
|
changed (which re-runs the PR checks), and replies on the PR with a summary.
|
||||||
|
|
||||||
|
Uses only the Python standard library plus the `claude` CLI.
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token`
|
||||||
|
GITEA_TOKEN run-scoped token (checkout auth handles the push)
|
||||||
|
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
|
||||||
|
GITEA_REPOSITORY owner/repo
|
||||||
|
PR_NUMBER pull request index
|
||||||
|
COMMENT_BODY the triggering comment text
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
TRIGGER = "@claude"
|
||||||
|
CLAUDE_TIMEOUT_S = 2400 # 40 min ceiling for one fix-up session
|
||||||
|
|
||||||
|
|
||||||
|
def api(path: str, payload: dict | None = None) -> dict:
|
||||||
|
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||||
|
repo = os.environ["GITEA_REPOSITORY"]
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{server}/api/v1/repos/{repo}{path}",
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST" if payload is not None else "GET",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: str, **kwargs) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
pr_number = os.environ["PR_NUMBER"]
|
||||||
|
instruction = os.environ["COMMENT_BODY"].strip()
|
||||||
|
if instruction.lower().startswith(TRIGGER):
|
||||||
|
instruction = instruction[len(TRIGGER):].strip()
|
||||||
|
if not instruction:
|
||||||
|
print("Empty instruction after trigger word; nothing to do")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pr = api(f"/pulls/{pr_number}")
|
||||||
|
head_ref = pr["head"]["ref"]
|
||||||
|
base_ref = pr["base"]["ref"]
|
||||||
|
|
||||||
|
run("git", "fetch", "origin", head_ref, base_ref)
|
||||||
|
run("git", "checkout", head_ref)
|
||||||
|
|
||||||
|
prompt = f"""You are working on pull request #{pr_number} ("{pr['title']}")
|
||||||
|
in the SchoolCompare repository. The PR branch is checked out; its base is
|
||||||
|
{base_ref}. A maintainer left this instruction on the PR:
|
||||||
|
|
||||||
|
{instruction}
|
||||||
|
|
||||||
|
Implement exactly what was asked, following the conventions in CLAUDE.md.
|
||||||
|
Run any relevant tests or typechecks you can. Do NOT commit or push — the
|
||||||
|
harness handles that. When done, summarise in a few sentences what you
|
||||||
|
changed and how you verified it."""
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
["claude", "-p", prompt, "--dangerously-skip-permissions", "--output-format", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=CLAUDE_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"claude CLI failed:\n{proc.stderr[-2000:]}")
|
||||||
|
summary = json.loads(proc.stdout)["result"].strip()
|
||||||
|
|
||||||
|
changed = run("git", "status", "--porcelain").stdout.strip()
|
||||||
|
if changed:
|
||||||
|
run("git", "config", "user.name", "Claude (CI)")
|
||||||
|
run("git", "config", "user.email", "noreply@anthropic.com")
|
||||||
|
run("git", "add", "-A")
|
||||||
|
title = instruction.splitlines()[0][:60]
|
||||||
|
run("git", "commit", "-m", f"ai: {title}\n\nRequested via PR comment; applied by Claude Code in CI.")
|
||||||
|
run("git", "push", "origin", head_ref)
|
||||||
|
sha = run("git", "rev-parse", "--short", "HEAD").stdout.strip()
|
||||||
|
reply = f"## 🤖 Claude fix-up applied (`{sha}`)\n\n{summary}\n\n_PR checks re-run automatically on the new commit._"
|
||||||
|
else:
|
||||||
|
reply = f"## 🤖 Claude fix-up — no changes made\n\n{summary}"
|
||||||
|
|
||||||
|
api(f"/issues/{pr_number}/comments", {"body": reply})
|
||||||
|
print(reply)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AI code review for Gitea pull requests, powered by Claude Code.
|
||||||
|
|
||||||
|
Reads the PR diff (base branch vs HEAD), asks Claude Code (headless `claude -p`)
|
||||||
|
to review it, posts the findings as a PR comment via the Gitea API, and exits
|
||||||
|
non-zero only when the review contains at least one severe finding — so the
|
||||||
|
job can gate merges without blocking on nitpicks.
|
||||||
|
|
||||||
|
Uses only the Python standard library; the review itself runs through the
|
||||||
|
Claude Code CLI, authenticated with a subscription OAuth token.
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token` (subscription auth)
|
||||||
|
GITEA_TOKEN Gitea access token for posting PR comments
|
||||||
|
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
|
||||||
|
GITEA_REPOSITORY owner/repo
|
||||||
|
PR_NUMBER pull request index
|
||||||
|
BASE_REF base branch name (e.g. main)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MAX_DIFF_CHARS = 150_000
|
||||||
|
|
||||||
|
PROMPT = """You are reviewing a pull request for SchoolCompare, a UK school
|
||||||
|
comparison site (FastAPI backend, Next.js frontend, Airflow/dbt data pipeline,
|
||||||
|
deployed via Gitea Actions to a staging-then-production Docker setup).
|
||||||
|
|
||||||
|
The PR diff is provided on stdin.
|
||||||
|
|
||||||
|
Report correctness bugs, security issues, data-loss risks, and broken deploy/CI
|
||||||
|
configuration. Mark a finding "severe" only if it would break production, leak
|
||||||
|
data, or corrupt data — severe findings block the merge. Everything else
|
||||||
|
(style, performance suggestions, minor cleanups) is "minor". Do not invent
|
||||||
|
findings: an empty findings list is a perfectly good review of a clean diff.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object (no markdown fences, no prose) of this shape:
|
||||||
|
{
|
||||||
|
"summary": "two or three sentences on what the change does and its health",
|
||||||
|
"findings": [
|
||||||
|
{"severity": "severe" | "minor", "file": "path", "issue": "description"}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_diff(base_ref: str) -> str:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "fetch", "origin", base_ref],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
diff = subprocess.run(
|
||||||
|
["git", "diff", f"origin/{base_ref}...HEAD"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
if len(diff) > MAX_DIFF_CHARS:
|
||||||
|
diff = diff[:MAX_DIFF_CHARS] + "\n\n[diff truncated for review]"
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
def review(diff: str) -> dict:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["claude", "-p", PROMPT, "--output-format", "json"],
|
||||||
|
input=diff,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=900,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"claude CLI failed:\n{proc.stderr}")
|
||||||
|
envelope = json.loads(proc.stdout)
|
||||||
|
result = envelope["result"].strip()
|
||||||
|
# Defensive: strip markdown fences if the model added them anyway
|
||||||
|
if result.startswith("```"):
|
||||||
|
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
|
||||||
|
return json.loads(result)
|
||||||
|
|
||||||
|
|
||||||
|
def format_comment(result: dict) -> str:
|
||||||
|
lines = ["## 🤖 AI Code Review (Claude Code)", "", result["summary"], ""]
|
||||||
|
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||||||
|
minor = [f for f in result["findings"] if f["severity"] == "minor"]
|
||||||
|
if severe:
|
||||||
|
lines.append("### 🔴 Severe (blocks merge)")
|
||||||
|
lines += [f"- **{f['file']}**: {f['issue']}" for f in severe]
|
||||||
|
lines.append("")
|
||||||
|
if minor:
|
||||||
|
lines.append("### 🟡 Minor")
|
||||||
|
lines += [f"- **{f['file']}**: {f['issue']}" for f in minor]
|
||||||
|
lines.append("")
|
||||||
|
if not result["findings"]:
|
||||||
|
lines.append("✅ No issues found.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def post_comment(body: str) -> None:
|
||||||
|
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||||
|
repo = os.environ["GITEA_REPOSITORY"]
|
||||||
|
pr = os.environ["PR_NUMBER"]
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{server}/api/v1/repos/{repo}/issues/{pr}/comments",
|
||||||
|
data=json.dumps({"body": body}).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
if resp.status >= 300:
|
||||||
|
raise RuntimeError(f"Comment post failed: HTTP {resp.status}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
diff = get_diff(os.environ["BASE_REF"])
|
||||||
|
if not diff.strip():
|
||||||
|
print("Empty diff, nothing to review")
|
||||||
|
return 0
|
||||||
|
result = review(diff)
|
||||||
|
comment = format_comment(result)
|
||||||
|
print(comment)
|
||||||
|
post_comment(comment)
|
||||||
|
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||||||
|
if severe:
|
||||||
|
print(f"\n{len(severe)} severe finding(s) — failing the check", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user