Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 9f8dba227c fix(compare): keep chart data when a client refetch fails
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m9s
The compare view refetches /api/compare on the client after SSR; on any
failure the catch nulled comparisonData, destroying the working
SSR-provided chart. A transient error (or staging's broken external /api
proxy) should not blank a comparison the user is already viewing — keep
the existing data instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:08:22 +01:00
tudor 18cd805c6c Merge pull request 'feat(compare): readable comparison chart on mobile' (#11) from feat/compare-chart-mobile-readability into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 49s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m6s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #11
2026-07-05 21:26:01 +00:00
TudorandClaude Fable 5 22769b6295 feat(compare): readable comparison chart on mobile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.

- Auto-fit the y-axis to the data on all viewports (computeYBounds in
  lib/utils: padded + min-span for percentages, symmetric around 0 for
  progress, fitted for scores; negative pct-named trend metrics are not
  zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
  as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
  dot + name) that doubles as tap-to-focus — highlights one school's
  line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
  whose integer-like URN keys enumerate in ascending numeric order,
  desyncing line colours from card colours; the chart now receives the
  ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.

Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:02:26 +01:00
tudor 90f2a02e75 Merge pull request 'fix(school): make hero map fullscreen work on iOS Safari' (#10) from fix/hero-map-ios-fullscreen into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
Reviewed-on: #10
2026-07-05 20:52:32 +00:00
TudorandClaude Fable 5 d52d384cf2 fix(school): make hero map fullscreen work on iOS Safari
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m15s
iOS Safari has no Element.requestFullscreen (fullscreen is video-only),
so tapping the map band or 'View on map' silently did nothing on
iPhones. Fall back to a fixed-position CSS overlay driven by state when
the Fullscreen API is missing or its promise rejects, locking body
scroll while open. The Leaflet map already re-measures via the shared
isFullscreen flag. New e2e journey simulates the iOS condition by
deleting the API and asserts the overlay opens and closes; it fails
against the current production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:34:22 +01:00
tudor ff606dad71 Merge pull request 'fix(e2e): pick the latest explicit year in the rankings year test' (#8) from fix/e2e-rankings-year-pick into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s
Reviewed-on: #8
2026-07-05 13:54:55 +00:00
TudorandClaude Fable 5 acec8135e1 fix(e2e): pick the latest explicit year in the rankings year test
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m24s
Staging doesn't always carry the full data history, so selecting the
oldest year legitimately returns no rows and fails the promotion gate.
Select the most recent explicit year instead: the default view already
proved it has rows, so an empty table after selecting it can only mean
the year query param was rejected — the regression this test guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:29:43 +01:00
tudor 0a370e3b63 Merge pull request 'fix(api): accept academic-year codes in rankings year filter' (#7) from fix/rankings-year-validation into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 21s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m3s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #7
2026-07-05 10:46:04 +00:00
TudorandClaude Fable 5 6c872ce726 fix(api): accept academic-year codes in rankings year filter
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m44s
The rankings endpoint validated year with le=2100, but the database
stores academic-year codes like 201819, so any explicit year selection
returned a 422 and the rankings page rendered its empty state. Widen
the bound to cover the codes and extend the e2e journey to pick a
specific year and assert the table stays populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:13:56 +01:00
tudor 23b4e1c453 Merge pull request 'fix(e2e): scroll detail-page chart into view before asserting' (#5) from fix/e2e-detail-chart-scroll into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 49s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 33s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
2026-07-03 13:56:00 +00:00
TudorandClaude Fable 5 deeef23131 fix(e2e): assert on a visible canvas — the first canvas is hidden by design
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The admissions card stacks year/trend views in one grid cell and keeps the
inactive view visibility:hidden; its canvas is first in the DOM. Use
canvas:visible instead of scrolling. Verified: full suite passes against prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:40:13 +01:00
TudorandClaude Fable 5 4ece55b031 fix(e2e): scroll the detail-page chart into view before asserting visibility
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 55s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 34s
The canvas renders below the fold and stays 'hidden' to Playwright until
scrolled to; wait for attachment, scroll, then assert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:34:17 +01:00
tudor 515494dbf0 Merge pull request 'fix(analytics): restrict Umami to production hostnames' (#4) from fix/umami-prod-domains-only into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 0s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m7s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 13:17:53 +00:00
TudorandClaude Fable 5 5772c54ccd fix(ci): use the documented GITHUB_TOKEN name for the run-scoped token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m27s
GITEA_TOKEN worked (the review comment posted with it) but GITHUB_TOKEN is
the documented name in Gitea Actions; use it to keep the reviewer and the
docs aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:53:57 +01:00
TudorandClaude Fable 5 c62ba0ca25 fix(ci): post AI review comments with the run-scoped Gitea token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m56s
REGISTRY_TOKEN lacks issue-write scope (403 on comment post). Gitea Actions
auto-provides a repo-scoped per-run token as secrets.GITEA_TOKEN — no
user-managed secret needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:39:59 +01:00
TudorandClaude Fable 5 b5a63e82d4 fix(analytics): restrict Umami to production hostnames
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 4s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 36s
The same frontend image runs on staging and prod (build once, promote), so a
build-time env var can't tell them apart. Umami's data-domains attribute
scopes the tracker client-side: events only fire when location.hostname is a
production domain, so staging traffic and the E2E journeys never pollute the
dashboards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:24:14 +01:00
tudor f5de745a8b Merge pull request 'fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN' (#3) from fix/ai-review-claude-code into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 18s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Failing after 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Has been skipped
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 08:50:26 +00:00
TudorandClaude Fable 5 d0895c71df fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN for PR comments
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 27s
- ai_review.py now pipes the diff through headless Claude Code (claude -p,
  --output-format json) authenticated with CLAUDE_CODE_OAUTH_TOKEN from
  'claude setup-token' — subscription auth, no Anthropic API billing
- stdlib-only script (urllib instead of requests/anthropic)
- PR comments posted with the existing REGISTRY_TOKEN secret; the separate
  GITEA_TOKEN secret is no longer needed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 08:28:20 +01:00
tudor df0bf1c4d6 Merge pull request 'feat(sdlc): staging environment + automated staging→prod pipeline' (#2) from feat/sdlc-staging-pipeline into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 19s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m10s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 06:17:40 +00:00
TudorandClaude Fable 5 4a52735356 feat(sdlc): staging environment + automated staging→prod pipeline
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 10m0s
PR Checks / Backend Smoke (pull_request) Successful in 48s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s
- pr-checks.yml: PR gate — frontend typecheck+jest, backend import smoke,
  image builds (no push), Claude AI review posted as PR comment (severe
  findings block merge)
- deploy.yml (replaces build-and-push.yml): merge to main builds+pushes
  images tagged sha-<sha>/staging, deploys the staging Portainer stack via
  webhook, runs Playwright E2E journeys against staging, then retags the
  verified images :prod (previous kept as :prod-previous) and deploys prod
- docker-compose.portainer.staging.yml: second Portainer stack — :staging
  images, sc_staging_* names, own macvlan IPs, Airflow on 8081; data
  bootstrapped from source via the staging Airflow DAGs
- prod compose now pins :prod instead of :latest (only the promotion step
  moves it; :latest is no longer published)
- e2e/: 6 Playwright journeys (search, postcode, detail, compare, rankings)
  driven by BASE_URL — the promotion gate
- scripts/ci/ai_review.py: Claude review with structured JSON findings
- docs/DEPLOY.md: full SDLC doc incl. one-time setup checklist and rollback
- replaced removed 'next lint' with tsc typecheck; fixed stale jest tests
  (slug URLs, N/A formatting, stable trend, fake-timer setup)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 06:50:40 +01:00
TudorandClaude Fable 5 f2ed49c0a1 feat(home): compact value-prop line on the mobile hero (P1.7)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Phones showed only the poetic h1 and a bare search box — no coverage,
scope, or freshness statement above the fold for the 63%-of-entries,
56%-mobile audience. One compact line ('24,000+ English schools — SATs,
GCSEs, Ofsted & admissions, side by side. Updated for 2026/27') now
replaces the hidden eyebrow + full paragraph at ≤640px, costing ~2 short
lines. Desktop copy unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:56:21 +01:00
TudorandClaude Fable 5 f24b8044f8 fix(rankings): long metric labels wrap instead of widening the table
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Table auto-layout sizes columns by unwrapped header text, so labels like
'Reading, Writing & Maths Combined Higher %' pushed the value column —
and the table — past the viewport. The label now renders inside a block
span capped at 110px (84px on phones), forcing multiline and keeping the
table fully in view at any metric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:40:30 +01:00
TudorandClaude Fable 5 29f79fe948 feat(rankings,metrics): score visible on phones; definitions usable everywhere (P2.2, P2.3)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Rankings mobile: the metric value — the point of the page — sat behind
a sideways swipe at 390px. The Area column now folds into a subline
under the school name, leaving Rank | School | Value to fit the
viewport with no horizontal scroll.

Rankings default metric becomes 'expected standard' (rwm_expected_pct);
'higher standard' stays available but no longer frames every school's
headline number in the terms parents least understand.

MetricTooltip was hover-only and display:none on phones — the mobile-
primary audience had zero access to the Attainment 8 / Progress 8 /
EBacc definitions. It is now a real button: tap/click/keyboard
toggleable with outside-click and Escape dismissal, 24px target,
shown at all viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:34:07 +01:00
TudorandClaude Fable 5 0294038fd3 fix(compare): shared ?urns= links win over the visitor's stored selection (P1.3)
The seed effect only adopted the URL's schools when localStorage was
empty, so a recipient who had ever used compare silently saw their own
old shortlist instead of the shared one. Explicit URL urns now replace
the stored selection on load (then persist as usual); bare /compare
still restores the visitor's own selection. Adds replaceSchools() to
the comparison context. Card values also switch to CHART_TEXT_COLORS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:34:07 +01:00
TudorandClaude Fable 5 a1fa4fe874 fix(a11y): WCAG AA colour contrast across every route (P0.1)
Live axe-core inventory found 13 failing fg/bg pairs (3.12:1 coral text/
fills, 4.0-4.5 teal near-misses, 2.7 gold badge, 1.9 chart-colour text).
All replacement values validated ≥4.5:1 against every background they
sit on:

- --accent-coral-dark deepened to #b04a2e and used for coral text roles
  (nav active, back/map links, kickers, chips) and coral fills under
  white text (btn-primary, phase tabs, section-nav compare, chart chips);
  new --accent-coral-darker #9c3f26 for their hovers. Decorative coral
  (pins, bars, borders, focus ring) keeps the brand #e07256.
- --accent-teal darkened to #296f6f (rankings values, metric rows,
  ofsted grades, eyebrows all pass).
- Ofsted green #3c8c3c→#2f7a2f; off-token gold #b8920e→--accent-gold-text.
- Gender-split pink text #b45778→#a04a68; admissions tip numerals to a
  3.8:1 muted brown (28px display text).
- New CHART_TEXT_COLORS: AA-dark counterparts of the Chart.js series
  palette for compare-card values (swatch dots keep true series colour).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:33:51 +01:00
48 changed files with 1717 additions and 247 deletions
@@ -1,12 +1,9 @@
name: Build and Push Docker Images
name: Deploy (staging -> E2E gate -> production)
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: privaterepo.sitaru.org
@@ -45,17 +42,15 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=backend-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
@@ -91,24 +86,20 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=frontend-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./nextjs-app
file: ./nextjs-app/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-args: |
FASTAPI_URL=http://backend:80/api
# Cache disabled due to registry size limits
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache,mode=max
build-pipeline:
name: Build Pipeline (Meltano + dbt + Airflow)
@@ -140,28 +131,110 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=pipeline-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Pipeline Docker image
uses: docker/build-push-action@v5
with:
context: ./pipeline
file: ./pipeline/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-pipeline.outputs.tags }}
labels: ${{ steps.meta-pipeline.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
trigger-deployment:
name: Trigger Portainer Update
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, build-pipeline]
if: gitea.event_name != 'pull_request'
steps:
- name: Trigger Portainer stack update
- name: Trigger staging stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_STAGING_WEBHOOK }}"
- name: Wait for staging to become healthy
run: |
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"
echo "Polling ${STAGING_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${STAGING_BASE_URL}/"; then
echo "Staging is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Staging did not become healthy in time" >&2
exit 1
env:
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
e2e-staging:
name: E2E Journeys against Staging
runs-on: ubuntu-latest
needs: [deploy-staging]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Playwright
working-directory: e2e
run: |
npm ci
npx playwright install --with-deps chromium
- name: Run E2E journeys
working-directory: e2e
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
promote-prod:
name: Promote to Production
runs-on: ubuntu-latest
needs: [e2e-staging]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag verified images as prod
run: |
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
+188
View File
@@ -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
+4 -1
View File
@@ -834,7 +834,10 @@ async def get_rankings(
request: Request,
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query(
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
None,
description="Academic year code, e.g. 201819 (defaults to most recent)",
ge=2000,
le=210100,
),
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
local_authority: Optional[str] = Query(
+14
View File
@@ -105,8 +105,22 @@ This starts:
- `GET /api/metrics` - Metric definitions (single source of truth)
- `GET /api/data-info` - Database stats
## SDLC
Full details in `docs/DEPLOY.md`. The short version:
- **Never push to `main` directly.** Work on a feature branch and open a PR;
branch protection requires the PR checks (typecheck, tests, builds, AI review)
to pass before merge.
- Merging to `main` deploys automatically: images are built once, deployed to
the **staging** Portainer stack, verified by the Playwright journeys in
`e2e/`, and only then retagged `:prod` and rolled out to production.
- If you change user-facing behaviour, update or extend the `e2e/` journey
tests in the same PR — they are the promotion gate.
## Recent Changes
- Added staging environment + automated staging→prod pipeline (Gitea Actions)
- Migrated from CSV file storage to PostgreSQL database
- Added location-based search using postcode geocoding
- Added local authority filter to rankings
+214
View File
@@ -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:
+5 -5
View File
@@ -36,7 +36,7 @@ services:
# ── FastAPI Backend ───────────────────────────────────────────────────
backend:
image: privaterepo.sitaru.org/tudor/school_compare-backend:latest
image: privaterepo.sitaru.org/tudor/school_compare-backend:prod
container_name: schoolcompare_backend
environment:
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
@@ -59,7 +59,7 @@ services:
# ── Next.js Frontend ──────────────────────────────────────────────────
frontend:
image: privaterepo.sitaru.org/tudor/school_compare-frontend:latest
image: privaterepo.sitaru.org/tudor/school_compare-frontend:prod
container_name: schoolcompare_nextjs
environment:
- NODE_ENV=production
@@ -103,7 +103,7 @@ services:
# ── Airflow API Server + UI ───────────────────────────────────────────
airflow-api-server:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_api
command: airflow api-server --port 8080
ports:
@@ -142,7 +142,7 @@ services:
# ── Airflow Scheduler ──────────────────────────────────────────────
airflow-scheduler:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_scheduler
command: airflow scheduler
environment:
@@ -172,7 +172,7 @@ services:
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
airflow-init:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_init
command: bash -c "airflow db migrate && airflow dags delete school_data_daily -y 2>/dev/null; airflow dags delete school_data_monthly_ofsted -y 2>/dev/null; airflow dags delete school_data_annual_ees -y 2>/dev/null; airflow dags reserialize"
environment:
+132
View File
@@ -0,0 +1,132 @@
# 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.
+3
View File
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
+78
View File
@@ -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"
}
}
}
}
+12
View File
@@ -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"
}
}
+13
View File
@@ -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',
},
});
+147
View File
@@ -0,0 +1,147 @@
import { test, expect, Page } from '@playwright/test';
/**
* Journey tests for SchoolCompare, run against the staging environment as the
* gate before promotion to production. They assert stable data invariants
* (results exist, key UI renders) rather than exact numbers, so routine data
* refreshes don't break the pipeline.
*/
async function searchByName(page: Page, query: string) {
await page.goto('/');
const searchInput = page.getByPlaceholder('School name or postcode').first();
await searchInput.fill(query);
await searchInput.press('Enter');
await page.waitForURL(/search=|postcode=/);
}
function schoolLinks(page: Page) {
return page.locator('a[href^="/school/"]');
}
test('home page loads with hero search', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible();
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
});
test('searching by name returns school results', async ({ page }) => {
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
expect(await schoolLinks(page).count()).toBeGreaterThan(1);
});
test('searching by postcode returns nearby schools', async ({ page }) => {
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
});
test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
await expect(page.locator('h1').first()).toBeVisible();
// The detail page renders at least one *visible* chart canvas. Plain
// .first() is wrong here: the admissions card stacks its year/trend views
// in one grid cell and keeps the inactive view's canvas visibility:hidden
// by design, and that canvas comes first in the DOM.
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
});
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
// CSS overlay. Simulate that by removing the API before any page script runs.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
const openMap = page.getByRole('button', { name: 'Open full map' });
await expect(openMap).toBeVisible({ timeout: 15_000 });
await openMap.click();
const closeMap = page.getByRole('button', { name: 'Close map' });
await expect(closeMap).toBeVisible();
await closeMap.click();
await expect(openMap).toBeVisible();
});
test('comparing two schools shows both side by side', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
// Both schools' detail links should render in the comparison view
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
});
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school inside the chart card.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
await expect(chips).toHaveCount(2);
// Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
test('rankings stay populated after picking a specific year', async ({ page }) => {
// Years are academic-year codes (e.g. 201819); the API must accept them
// as the `year` query param rather than rejecting with a 422.
await page.goto('/rankings');
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
// Pick the last option — the most recent explicit year. The default view
// already proved this year has rows, so an empty table after selecting it
// can only mean the year param was rejected. (The oldest year is no good
// here: staging doesn't always carry the full data history.)
const yearValue = await yearSelect.locator('option').last().getAttribute('value');
expect(yearValue).toBeTruthy();
await yearSelect.selectOption(yearValue!);
await page.waitForURL(/year=/);
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
@@ -34,24 +34,24 @@ describe('SchoolCard', () => {
render(<SchoolCard school={mockSchool} />);
const link = screen.getByRole('link', { name: /test primary school/i });
expect(link).toHaveAttribute('href', '/school/100001');
expect(link).toHaveAttribute('href', '/school/100001-test-primary-school');
});
it('calls onAddToCompare when Add to Compare button is clicked', () => {
it('calls onAddToCompare when the Compare button is clicked', () => {
const mockAddToCompare = jest.fn();
render(<SchoolCard school={mockSchool} onAddToCompare={mockAddToCompare} />);
const addButton = screen.getByText('Add to Compare');
const addButton = screen.getByText('+ Compare');
fireEvent.click(addButton);
expect(mockAddToCompare).toHaveBeenCalledWith(mockSchool);
expect(mockAddToCompare).toHaveBeenCalledTimes(1);
});
it('does not render Add to Compare button when handler not provided', () => {
it('does not render the Compare button when handler not provided', () => {
render(<SchoolCard school={mockSchool} />);
expect(screen.queryByText('Add to Compare')).not.toBeInTheDocument();
expect(screen.queryByText('+ Compare')).not.toBeInTheDocument();
});
it('displays trend indicator for positive change', () => {
+65 -8
View File
@@ -9,6 +9,8 @@ import {
isValidPostcode,
debounce,
buildOfstedListBadge,
metricKind,
computeYBounds,
} from '@/lib/utils';
describe('formatPercentage', () => {
@@ -19,7 +21,7 @@ describe('formatPercentage', () => {
});
it('handles null values', () => {
expect(formatPercentage(null)).toBe('-');
expect(formatPercentage(null)).toBe('N/A');
});
});
@@ -31,7 +33,7 @@ describe('formatProgress', () => {
});
it('handles null values', () => {
expect(formatProgress(null)).toBe('-');
expect(formatProgress(null)).toBe('N/A');
});
});
@@ -44,16 +46,16 @@ describe('calculateTrend', () => {
expect(calculateTrend(70, 75)).toBe('down');
});
it('calculates same trend', () => {
expect(calculateTrend(75, 75)).toBe('same');
it('calculates stable trend', () => {
expect(calculateTrend(75, 75)).toBe('stable');
});
it('handles null previous value', () => {
expect(calculateTrend(75, null)).toBe('same');
expect(calculateTrend(75, null)).toBe('stable');
});
it('handles null current value', () => {
expect(calculateTrend(null, 75)).toBe('same');
expect(calculateTrend(null, 75)).toBe('stable');
});
});
@@ -72,7 +74,13 @@ describe('isValidPostcode', () => {
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('delays function execution', () => {
const mockFn = jest.fn();
@@ -100,8 +108,6 @@ describe('debounce', () => {
expect(mockFn).toHaveBeenCalledWith('third');
expect(mockFn).toHaveBeenCalledTimes(1);
});
jest.useRealTimers();
});
describe('buildOfstedListBadge', () => {
@@ -155,3 +161,54 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofstedPending');
});
});
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
+18 -17
View File
@@ -16,8 +16,9 @@
--text-inverse: #faf7f2;
--accent-coral: #e07256;
--accent-coral-dark: #c45a3f;
--accent-teal: #2d7d7d;
--accent-coral-dark: #b04a2e; /* AA: 4.5+ as text on white/cream tints; 5.4:1 under white text */
--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-gold: #c9a227;
--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) */
.btn-primary {
background: var(--accent-coral);
background: var(--accent-coral-dark);
color: white;
border-color: var(--accent-coral);
border-color: var(--accent-coral-dark);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-coral-dark);
border-color: var(--accent-coral-dark);
background: var(--accent-coral-darker);
border-color: var(--accent-coral-darker);
}
/* Secondary: teal outline — supporting actions (+ Compare) */
@@ -210,8 +211,8 @@ body {
}
.btn-active:hover:not(:disabled) {
background: transparent;
color: var(--accent-coral);
border-color: var(--accent-coral);
color: var(--accent-coral-dark);
border-color: var(--accent-coral-dark);
}
/* Small variant */
@@ -745,14 +746,14 @@ body {
}
.btn-compare {
background: var(--accent-coral);
background: var(--accent-coral-dark);
color: white;
border: 1px solid var(--accent-coral);
border: 1px solid var(--accent-coral-dark);
}
.btn-compare:hover {
background: #d4654a;
border-color: #d4654a;
background: var(--accent-coral-darker);
border-color: var(--accent-coral-darker);
}
.btn-compare.active {
@@ -916,7 +917,7 @@ body {
}
.stat-value.negative {
color: var(--accent-coral);
color: var(--accent-coral-dark);
}
/* Trend indicators */
@@ -930,7 +931,7 @@ body {
}
.trend-down {
color: var(--accent-coral);
color: var(--accent-coral-dark);
}
.trend-stable {
@@ -1018,7 +1019,7 @@ body {
}
.map-modal-close:hover {
color: var(--accent-coral);
color: var(--accent-coral-dark);
}
.map-modal-content {
@@ -1567,12 +1568,12 @@ body {
}
.btn-primary {
background: var(--accent-coral);
background: var(--accent-coral-dark);
color: white;
}
.btn-primary:hover {
background: var(--accent-coral-dark);
background: var(--accent-coral-darker);
transform: translateY(-1px);
}
+3
View File
@@ -76,9 +76,12 @@ export default function RootLayout({
<head>
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
<link rel="preconnect" href="https://api.postcodes.io" />
{/* data-domains: the tracker only fires on the production hostnames,
so staging (same image, different host) never pollutes Umami */}
<Script
src="https://analytics.schoolcompare.co.uk/script.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
data-performance="true"
strategy="afterInteractive"
/>
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
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;
// Fetch rankings data with error handling
@@ -196,7 +196,7 @@
margin-bottom: 0.15rem;
}
.chipTrackDeadline { color: var(--accent-coral, #e07256); }
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
.chipTrackDot {
@@ -278,7 +278,7 @@
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
margin-bottom: 0.35rem;
}
@@ -399,7 +399,7 @@
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); }
.stepTitle {
@@ -472,7 +472,7 @@
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.75rem;
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;
margin-bottom: 0.6rem;
}
@@ -0,0 +1,63 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
parent .chartContainer, whose fixed height drives Chart.js sizing via
maintainAspectRatio: false. */
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.canvasBox {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
Chart.js's built-in legend (with per-school point shapes). */
.chips {
display: none;
}
@media (max-width: 640px) {
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
max-width: 100%;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
}
.chipDot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
}
.chipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 9rem;
}
}
+116 -45
View File
@@ -1,47 +1,82 @@
/**
* ComparisonChart Component
* Multi-school comparison chart using Chart.js
* Multi-school comparison chart using Chart.js.
*
* Desktop: built-in legend (point-style markers double as per-school shapes).
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
* of a chip row above the canvas; tapping a chip highlights that school's
* line and dims the rest. The y-axis auto-fits the data on all viewports so
* clustered schools stay distinguishable.
*/
'use client';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
import {
CHART_COLORS,
CHART_TEXT_COLORS,
computeYBounds,
formatAcademicYear,
metricKind,
rgbToRgba,
} from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './ComparisonChart.module.css';
interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>;
/** Ordered as displayed in the school cards, so colours match by index. */
schools: Array<{ urn: number; school_name: string }>;
metric: string;
metricLabel: string;
}
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// Get all schools and their data
const schools = Object.entries(comparisonData);
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
const urnKey = schools.map((s) => s.urn).join(',');
useEffect(() => {
setFocusedUrn(null);
}, [urnKey]);
if (schools.length === 0) {
return <div>No data available</div>;
}
// Get years from first school (assuming all schools have same years)
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
// Union of years across all schools — coverage differs between them.
const years = [
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
// Create datasets for each school
const datasets = schools.map(([urn, data], index) => {
const schoolInfo = data.school_info;
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const data = comparisonData[String(school.urn)];
const color = CHART_COLORS[index % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
label: schoolInfo.school_name,
label: school.school_name,
data: years.map((year) => {
const yearData = data.yearly_data.find((d) => d.year === year);
const yearData = data?.yearly_data.find((d) => d.year === year);
if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null;
}),
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3,
spanGaps: true,
};
@@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
datasets,
};
// Determine if metric is a progress score or percentage
const isProgressScore = metric.includes('progress');
const isPercentage = metric.includes('pct') || metric.includes('rate');
const kind = metricKind(metric);
const yBounds = computeYBounds(
datasets.flatMap((ds) => ds.data as Array<number | null>),
kind,
);
const options: ChartOptions<'line'> = {
responsive: true,
@@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
plugins: {
legend: {
display: !isMobile,
position: 'top' as const,
labels: {
usePointStyle: true,
@@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
},
},
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: {
display: true,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
padding: isMobile ? 10 : 12,
titleFont: {
size: 14,
size: isMobile ? 12 : 14,
},
bodyFont: {
size: 13,
size: isMobile ? 11 : 13,
},
usePointStyle: true,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: {
label: function (context) {
let label = context.dataset.label || '';
@@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
label += ': ';
}
if (context.parsed.y !== null) {
if (isProgressScore) {
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
} else {
label += 'N/A';
}
@@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
type: 'linear' as const,
display: true,
title: {
display: true,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
font: {
size: 12,
weight: 'bold',
},
},
...(isPercentage && {
min: 0,
max: 100,
}),
...yBounds,
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxTicksLimit: 5 }),
},
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
@@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
display: false,
},
title: {
display: true,
display: !isMobile,
text: 'Year',
font: {
size: 12,
weight: 'bold',
},
},
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
},
},
};
return <Line data={chartData} options={options} />;
const toggleFocus = (urn: number) => {
const next = focusedUrn === urn ? null : urn;
setFocusedUrn(next);
if (next !== null) track('compare_focus_school', { urn: next });
};
return (
<div className={styles.wrapper}>
{/* Mobile legend + focus control; a single series needs no legend. */}
{schools.length > 1 && (
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
{schools.map((school, index) => (
<button
key={school.urn}
type="button"
className={styles.chip}
aria-pressed={focusedUrn === school.urn}
onClick={() => toggleFocus(school.urn)}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span
className={styles.chipName}
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
>
{school.school_name}
</span>
</button>
))}
</div>
)}
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
</div>
);
}
@@ -121,7 +121,7 @@
}
.removeSchoolBtn:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.toastActions {
@@ -145,7 +145,7 @@
}
.btnClearAll:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.btnCompare {
@@ -63,13 +63,13 @@
}
.phaseTabActive {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-darker, #9c3f26);
}
/* Metric Selector */
@@ -198,7 +198,7 @@
}
.schoolName a:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.schoolMeta {
@@ -454,7 +454,10 @@
}
.chartContainer {
height: 300px;
/* Taller than desktop's proportion would suggest: the chip legend row
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
}
.comparisonTable {
+21 -11
View File
@@ -18,7 +18,7 @@ import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
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 { track } from '@/lib/analytics';
import styles from './ComparisonView.module.css';
@@ -58,7 +58,7 @@ export function ComparisonView({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool, addSchool, isInitialized } = useComparison();
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
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.
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(() => {
if (!isInitialized) return;
if (selectedSchools.length === 0 && initialUrns.length > 0 && initialData) {
initialUrns.forEach(urn => {
const data = initialData[String(urn)];
if (data?.school_info) {
addSchool(data.school_info);
if (initialUrns.length > 0 && initialData) {
const urlSchools = initialUrns
.map(urn => initialData[String(urn)]?.school_info)
.filter((info): info is NonNullable<typeof info> => Boolean(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
@@ -105,8 +111,10 @@ export function ComparisonView({
setComparisonData(data.comparison);
})
.catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
setComparisonData(null);
});
} else {
setComparisonData(null);
@@ -378,7 +386,8 @@ export function ComparisonView({
{activeComparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
{/* 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
style={{
display: 'inline-block',
@@ -422,6 +431,7 @@ export function ComparisonView({
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric}
metricLabel={metricLabel}
/>
+1 -1
View File
@@ -29,7 +29,7 @@
.bad {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.neutral {
+28 -12
View File
@@ -62,6 +62,11 @@
font-weight: 700;
}
/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
.heroDescriptionCompact {
display: none;
}
@media (max-width: 768px) {
.heroSection {
padding-top: 1.5rem;
@@ -74,18 +79,29 @@
}
}
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
long descriptive paragraph — the h1 already names the product, and the
search input is the primary action users came to perform. */
/* Above the fold on phones, every line costs. Drop the eyebrow tag and swap
the long descriptive paragraph for one compact line that carries coverage
("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
visitor otherwise sees only a poetic h1 and a bare box, with no reason to
trust the data (audit P1.7, feeds the 46% home exit rate). */
@media (max-width: 640px) {
.heroSection {
padding-top: 0.75rem;
margin-bottom: 1rem;
}
.heroEyebrow,
.heroDescription {
.heroDescriptionFull {
display: none;
}
.heroDescriptionCompact {
display: block;
}
.heroDescription {
font-size: 0.85rem;
line-height: 1.4;
max-width: 320px;
margin-bottom: 0.25rem;
}
.heroTitle {
font-size: 1.65rem;
}
@@ -121,7 +137,7 @@
.viewToggleBtn.active {
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);
}
@@ -222,7 +238,7 @@
}
.compactItemName:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.distanceBadge {
@@ -536,7 +552,7 @@
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
@@ -567,9 +583,9 @@
}
.quickSearchChip:hover {
background: var(--accent-coral);
background: var(--accent-coral-dark);
color: white;
border-color: var(--accent-coral);
border-color: var(--accent-coral-dark);
}
.exploringRow {
@@ -712,7 +728,7 @@
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.hiwTitle {
@@ -1062,7 +1078,7 @@
.editorialKicker {
font-size: 0.68rem;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -1300,7 +1316,7 @@
}
.chipTrackDeadline {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.chipTrackOffer {
+10 -2
View File
@@ -267,7 +267,15 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
Every school in England, <em className={styles.heroEmph}>compared.</em>
</h1>
<p className={styles.heroDescription}>
{/* Full copy for larger screens; a compact merged line (coverage +
freshness, standing in for the hidden eyebrow too) on phones,
where every line above the fold costs. */}
<span className={styles.heroDescriptionFull}>
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data side by side, in one place.
</span>
<span className={styles.heroDescriptionCompact}>
<strong>24,000+ English schools</strong> SATs, GCSEs, Ofsted &amp; admissions, side by side. Updated for 2026/27.
</span>
</p>
</div>
)}
@@ -620,8 +628,8 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
borderRadius: '3px',
whiteSpace: 'nowrap',
...(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 === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: '#b8920e' } :
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
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 === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
+2 -2
View File
@@ -49,8 +49,8 @@ function buildPopupBadge(school: School): PopupBadge {
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
const colours: Record<number, string> = {
1: 'background:#d4f0ea;color:#2d7d7d',
2: 'background:rgba(60,140,60,0.12);color:#3c8c3c',
3: 'background:#fef3cd;color:#b8920e',
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
3: 'background:#fef3cd;color:#7a6800',
4: 'background:#fde8e0;color:#e07256',
};
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
+33 -12
View File
@@ -6,7 +6,17 @@
}
.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);
cursor: help;
line-height: 1;
@@ -14,8 +24,9 @@
transition: color 0.15s ease;
}
.wrapper:hover .icon {
color: var(--accent-coral, #e07256);
.wrapper:hover .icon,
.icon[aria-expanded="true"] {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
@@ -39,12 +50,18 @@
transition: opacity 0.15s ease, visibility 0.15s ease;
}
/* Keep tooltip visible when hovering over it */
.wrapper:hover .tooltip {
/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
.wrapper:hover .tooltip,
.wrapper:focus-within .tooltip,
.tooltipOpen {
visibility: visible;
opacity: 1;
}
.tooltipOpen {
pointer-events: auto;
}
/* Small arrow pointing down */
.tooltip::after {
content: '';
@@ -75,19 +92,23 @@
margin-top: 0.1rem;
}
/* Flip tooltip below when near top of screen */
@media (max-width: 480px) {
.tooltip {
width: 180px;
}
}
/* On phones the icon was rendering at ~9px and the tooltip relied on
:hover, which doesn't fire on touch. Rather than build a tap-to-show
layer with backdrop dismissal, hide the helper entirely — the metric
labels themselves carry the meaning. */
/* Anchor the bubble to open rightward on phones — icons follow their labels,
which start at the left edge, so centring pushed the bubble off-screen. */
@media (max-width: 640px) {
.wrapper {
display: none;
.tooltip {
left: -12px;
right: auto;
transform: none;
}
.tooltip::after {
left: 16px;
transform: none;
}
}
+35 -3
View File
@@ -1,5 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import styles from './MetricTooltip.module.css';
@@ -16,12 +17,43 @@ export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltip
const tooltipPlain = plain ?? explanation?.plain;
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;
return (
<span className={styles.wrapper}>
<span className={styles.icon} aria-label={tooltipLabel ?? 'More information'} role="img"></span>
<span className={styles.tooltip} role="tooltip">
<span className={styles.wrapper} ref={wrapperRef}>
<button
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>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
+3 -3
View File
@@ -35,7 +35,7 @@
}
.logo:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.logoIcon {
@@ -100,7 +100,7 @@
}
.navLink.active {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-bg);
}
@@ -167,7 +167,7 @@
}
.tabActive {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.tabIconWrap {
@@ -87,7 +87,7 @@
}
.chipActive {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
border-color: var(--accent-coral, #e07256);
font-weight: 600;
+3 -11
View File
@@ -10,12 +10,13 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css';
@@ -68,16 +69,7 @@ export function PerformanceChart({
const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year));
// ── Mobile detection ─────────────────────────────────────────────────
// Hydration-safe: SSR renders desktop; client flips to mobile after mount.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
const isMobile = useIsMobile();
// ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = sortedData.map(d => {
+40 -10
View File
@@ -55,13 +55,13 @@
}
.phaseTabActive {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-darker, #9c3f26);
}
/* Filters */
@@ -187,6 +187,18 @@
text-align: center;
}
/* Table auto-layout sizes columns by the unwrapped text, so a long metric
label ("Reading, Writing & Maths Combined Higher %") widened the table
past the viewport. A block inner span is what actually caps the measured
width and forces the label onto multiple lines. */
.valueHeaderText {
display: block;
max-width: 110px;
margin: 0 auto;
white-space: normal;
line-height: 1.2;
}
.actionHeader {
width: 120px;
text-align: center;
@@ -290,7 +302,7 @@
}
.schoolLink:hover {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.areaCell,
@@ -298,6 +310,14 @@
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 {
text-align: center;
font-size: 1rem;
@@ -374,20 +394,26 @@
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,
.typeCell,
.actionHeader,
.actionCell {
.actionCell,
.areaHeader,
.areaCell {
display: none;
}
.schoolHeader {
min-width: 140px;
.schoolCellArea {
display: block;
margin-top: 0.15rem;
}
.areaHeader {
min-width: 80px;
.schoolHeader {
min-width: 0;
}
.valueHeader,
@@ -397,7 +423,7 @@
}
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
force the whole column wide; let them wrap onto 2 short lines with a
force the whole column wide; let them wrap onto short lines with a
tighter font so the value cell can stay compact. */
.valueHeader {
font-size: 0.625rem;
@@ -406,6 +432,10 @@
letter-spacing: 0.03em;
}
.valueHeaderText {
max-width: 84px;
}
.rankHeader {
width: 40px;
}
+11 -2
View File
@@ -75,7 +75,7 @@ export function RankingsView({
};
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 });
};
@@ -233,7 +233,11 @@ export function RankingsView({
<th className={styles.schoolHeader}>School</th>
<th className={styles.areaHeader}>Area</th>
<th className={styles.typeHeader}>Type</th>
<th className={styles.valueHeader}>{metricLabel}</th>
<th className={styles.valueHeader}>
{/* Inner block caps the column's measured width so long
labels wrap instead of widening the table off-screen. */}
<span className={styles.valueHeaderText}>{metricLabel}</span>
</th>
<th className={styles.actionHeader}>Action</th>
</tr>
</thead>
@@ -273,6 +277,11 @@ export function RankingsView({
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
{ranking.school_name}
</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 className={styles.areaCell}>{ranking.local_authority || '-'}</td>
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
@@ -13,7 +13,7 @@
padding: 0.25rem 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: none;
cursor: pointer;
@@ -76,7 +76,7 @@
.headerHasMap .actions .btnAdd {
background: rgba(255, 255, 255, 0.9);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
@@ -124,7 +124,7 @@
padding: 0;
font: inherit;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
cursor: pointer;
white-space: nowrap;
}
@@ -210,7 +210,7 @@
}
.genderSplitGirls {
color: #b45778;
color: #a04a68;
}
.genderSplitBoys {
@@ -242,7 +242,7 @@
}
.genderBarGirls {
background: #b45778;
background: #a04a68;
}
.genderBarBoys {
@@ -270,12 +270,12 @@
}
.btnAdd {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
}
.btnAdd:hover {
background: var(--accent-coral-dark, #c45a3f);
background: var(--accent-coral-darker, #9c3f26);
transform: translateY(-1px);
}
@@ -323,7 +323,7 @@
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
@@ -399,7 +399,7 @@
}
.sectionNavLinkActive {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
@@ -468,8 +468,8 @@
width: 38px;
height: 38px;
border-radius: 9px;
border: 1px solid var(--accent-coral, #e07256);
background: var(--accent-coral, #e07256);
border: 1px solid var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-dark, #b04a2e);
color: white;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
@@ -513,8 +513,8 @@
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral, #e07256);
border: 1px solid var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
border: 1px solid var(--accent-coral-dark, #b04a2e);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
@@ -522,8 +522,8 @@
}
.sectionNavCompare:hover {
background: var(--accent-coral-dark, #c45a3f);
border-color: var(--accent-coral-dark, #c45a3f);
background: var(--accent-coral-darker, #9c3f26);
border-color: var(--accent-coral-darker, #9c3f26);
}
.sectionNavCompareIn {
@@ -626,7 +626,7 @@
}
.sectionsTick {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
/* On phones the menu becomes a bottom sheet. */
@@ -856,7 +856,7 @@
}
.progressNegative {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
font-weight: 700;
}
@@ -868,12 +868,12 @@
.statusWarn {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
}
.statusBad {
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
/* Charts Section */
@@ -1011,15 +1011,15 @@
}
.ofstedGrade2 {
background: rgba(60, 140, 60, 0.12);
color: #3c8c3c;
color: #2f7a2f;
}
.ofstedGrade3 {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
}
.ofstedGrade4 {
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) */
@@ -1029,11 +1029,11 @@
} /* Exceptional */
.rcGrade2 {
background: rgba(60, 140, 60, 0.12);
color: #3c8c3c;
color: #2f7a2f;
} /* Strong */
.rcGrade3 {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
} /* Expected standard */
.rcGrade4 {
background: rgba(249, 115, 22, 0.12);
@@ -1041,7 +1041,7 @@
} /* Needs attention */
.rcGrade5 {
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
} /* Urgent improvement */
/* Safeguarding value (used inside a standard metricCard) */
@@ -1061,7 +1061,7 @@
font-size: 0.8125rem;
font-weight: 700;
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.ofstedDisclaimer {
@@ -34,6 +34,15 @@
background: #fff;
}
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
can't promote the element, so pin it over the page ourselves. Above the
comparison toast (3000) and everything else except modals (9999+). */
.wrapper[data-fs-fallback] {
position: fixed;
inset: 0;
z-index: 5000;
}
.skeleton {
width: 100%;
height: 100%;
+29 -4
View File
@@ -29,25 +29,50 @@ interface SchoolHeroMapProps {
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
function SchoolHeroMap({ lat, lng }, ref) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a
// fixed-position overlay driven by state instead of the Fullscreen API.
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || fallbackFullscreen;
const open = useCallback(() => {
wrapperRef.current?.requestFullscreen?.().catch(() => {});
const el = wrapperRef.current;
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, []);
const close = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
setFallbackFullscreen(false);
}, []);
useImperativeHandle(ref, () => ({ open }), [open]);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
const onChange = () => setNativeFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
// The fallback overlay sits on top of the page rather than replacing it,
// so lock body scroll while it is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
return (
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
<div
ref={wrapperRef}
className={styles.wrapper}
data-fullscreen={isFullscreen || undefined}
data-fs-fallback={fallbackFullscreen || undefined}
>
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
{isFullscreen ? (
+2 -2
View File
@@ -201,8 +201,8 @@
}
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
@@ -13,7 +13,7 @@
padding: 0.25rem 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: none;
cursor: pointer;
@@ -76,7 +76,7 @@
.headerHasMap .actions .btnAdd {
background: rgba(255, 255, 255, 0.9);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
@@ -124,7 +124,7 @@
padding: 0;
font: inherit;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
cursor: pointer;
white-space: nowrap;
}
@@ -226,12 +226,12 @@
}
.btnAdd {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
}
.btnAdd:hover {
background: var(--accent-coral-dark, #c45a3f);
background: var(--accent-coral-darker, #9c3f26);
transform: translateY(-1px);
}
@@ -278,7 +278,7 @@
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
@@ -322,13 +322,13 @@
}
.tabBtnActive {
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.tabBtnActive:hover {
background: var(--accent-coral-dark, #c45a3f);
background: var(--accent-coral-darker, #9c3f26);
color: white;
}
@@ -456,7 +456,7 @@
}
.progressNegative {
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
font-weight: 700;
}
@@ -468,7 +468,7 @@
.statusWarn {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
}
/* ── Metric table (row-based) ────────────────────────── */
@@ -582,15 +582,15 @@
}
.ofstedGrade2 {
background: rgba(60, 140, 60, 0.12);
color: #3c8c3c;
color: #2f7a2f;
}
.ofstedGrade3 {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
}
.ofstedGrade4 {
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.rcGrade1 {
@@ -599,11 +599,11 @@
}
.rcGrade2 {
background: rgba(60, 140, 60, 0.12);
color: #3c8c3c;
color: #2f7a2f;
}
.rcGrade3 {
background: var(--accent-gold-bg);
color: #b8920e;
color: var(--accent-gold-text, #7a6800);
}
.rcGrade4 {
background: rgba(249, 115, 22, 0.12);
@@ -611,7 +611,7 @@
}
.rcGrade5 {
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.safeguardingMet {
@@ -631,7 +631,7 @@
font-size: 0.8125rem;
font-weight: 700;
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
color: var(--accent-coral-dark, #b04a2e);
}
.ofstedDisclaimer {
@@ -874,7 +874,7 @@
}
.genderBarGirls {
background: #b45778;
background: #a04a68;
}
.genderBarBoys {
@@ -889,7 +889,7 @@
}
.genderSplitGirls {
color: #b45778;
color: #a04a68;
font-weight: 600;
}
@@ -946,7 +946,7 @@
position: absolute;
top: -20px;
transform: translateX(-50%);
background: var(--accent-coral, #e07256);
background: var(--accent-coral-dark, #b04a2e);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
@@ -125,7 +125,7 @@
white-space: nowrap;
}
.deltaPositive { color: #3c8c3c; }
.deltaPositive { color: #2f7a2f; }
.deltaNegative { color: var(--accent-coral, #e07256); }
/* Line 4: location + distance */
@@ -203,8 +203,8 @@
}
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
+1
View File
@@ -15,6 +15,7 @@ interface ComparisonContextType {
error: any;
addSchool: (school: School) => void;
removeSchool: (urn: number) => void;
replaceSchools: (schools: School[]) => void;
clearAll: () => void;
isSelected: (urn: number) => boolean;
canAddMore: boolean;
@@ -65,6 +65,12 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
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(() => {
setSelectedSchools([]);
}, []);
@@ -86,6 +92,7 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
error: null,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore: selectedSchools.length < MAX_SCHOOLS,
+2
View File
@@ -15,6 +15,7 @@ export function useComparison() {
selectedSchools,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
@@ -39,6 +40,7 @@ export function useComparison() {
error,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
+23
View File
@@ -0,0 +1,23 @@
/**
* Viewport hook shared by the chart components.
* Hydration-safe: SSR and the first client render report desktop; the
* media-query subscription flips the value after mount.
*/
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile(maxWidth = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return isMobile;
}
+1
View File
@@ -29,6 +29,7 @@ export type EventName =
| 'compare_viewed'
| 'compare_metric_changed'
| 'compare_shared'
| 'compare_focus_school'
// Operational
| 'api_error'
| 'results_load_more';
+72
View File
@@ -275,6 +275,27 @@ export function getChartColor(index: number): string {
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
*/
@@ -296,6 +317,57 @@ export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
}
}
/**
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
*/
export type MetricKind = 'percentage' | 'progress' | 'score';
export function metricKind(metric: string): MetricKind {
if (metric.includes('progress')) return 'progress';
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
return 'score';
}
/**
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
* series remain distinguishable. Padding keeps a minimum span so noise is
* not magnified into drama.
*
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
* data is non-negative (some trend metrics have `pct` in the key but hold
* negative year-over-year deltas).
* - progress: symmetric around 0 so the zero line always shows.
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
* 0 only when the data is non-negative.
*/
export function computeYBounds(
values: Array<number | null | undefined>,
kind: MetricKind,
): { min?: number; max?: number } {
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (nums.length === 0) return {};
const lo = Math.min(...nums);
const hi = Math.max(...nums);
if (kind === 'progress') {
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
return { min: -reach, max: reach };
}
if (kind === 'percentage') {
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
const min = Math.floor((lo - pad) / 5) * 5;
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
return { min: lo >= 0 ? Math.max(0, min) : min, max };
}
// score
const pad = Math.max(2, (hi - lo) * 0.2);
const min = Math.floor(lo - pad);
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
}
// ============================================================================
// Local Storage Utilities
// ============================================================================
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
+137
View File
@@ -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())