Compare commits

71 Commits

Author SHA1 Message Date
Tudor Sitaru
5b025b98bd fix(dim_school): use case-insensitive comparison for phase inference
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m6s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
GIAS provides 'Not Applicable' (capital A) but the check used 'Not applicable',
so the case-sensitive != matched true and skipped the age-range inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:33:04 +01:00
Tudor Sitaru
4c3c3c882d fix(dim_school): infer phase from age range for independent schools
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m9s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Independent schools have phase='Not applicable' in GIAS. Now infer
phase from statutory age range: <=11 → Primary, >=11 → Secondary,
spans both → All-through. Falls back to original value if no age data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:18:52 +01:00
Tudor Sitaru
d591d8e66b fix(utils): handle null year in formatAcademicYear
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
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 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:07:28 +01:00
Tudor Sitaru
4db36b9099 feat(ui): add phase indicators to school list rows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Add coloured left-border and phase label pill to visually differentiate
school phases (Primary, Secondary, All-through, Post-16, Nursery) in
search result lists. Colours are accessible (WCAG AA) and don't clash
with existing Ofsted/trend semantic colours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:47:51 +01:00
Tudor Sitaru
cacbeeb068 fixing backend image
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
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 1s
2026-04-01 15:05:21 +01:00
Tudor Sitaru
d5f6366c28 fix(years): format academic years as 2016/17 across all views, remove legacy frontend and data
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Failing after 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Apply formatAcademicYear to all year displays in ComparisonChart, ComparisonView,
PerformanceChart, and RankingsView. Remove old vanilla JS frontend and CSV data
directory — both superseded by the Next.js app and Meltano pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 14:58:13 +01:00
Tudor Sitaru
2b757e556d fix(legacy-ks2): strip % suffix from percentage values
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m37s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Old DfE CSVs encode percentages as "57%" not "57". The safe_numeric
macro rejects non-numeric strings, so strip the suffix before emitting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:07:51 +01:00
Tudor Sitaru
fbd1de9220 fix(dag): add stg_legacy_ks2 to annual EES dbt build selector
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m29s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 11:27:29 +01:00
Tudor Sitaru
fba8e74b72 refactor(legacy-ks2): use explicit year→URL mapping instead of base URL pattern
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The file hosting uses non-deterministic URLs, so replace legacy_ks2_base_url
+ legacy_ks2_years with a single legacy_ks2_urls object mapping year codes
to download URLs. Configure the 4 pre-COVID years in meltano.yml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 22:44:11 +01:00
Tudor Sitaru
6d4962639c feat(legacy-ks2): add stream for pre-COVID KS2 data (2015-2019)
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m17s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Add LegacyKS2Stream to tap-uk-ees: downloads old DfE england_ks2final.csv
  files from a configurable base URL, maps 318-column wide format to the
  same schema as stg_ees_ks2 output
- Add stg_legacy_ks2.sql staging model with safe_numeric casts
- Add legacy_ks2 source to _stg_sources.yml
- Update int_ks2_with_lineage.sql to union EES + legacy data
- Configurable via legacy_ks2_base_url and legacy_ks2_years tap settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:36:41 +01:00
Tudor Sitaru
fc011c6547 fix(tap-uk-ees): case-insensitive URN column matching for older census files
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Older census CSVs use 'URN' (uppercase) while the stream expects 'urn'.
Normalise the column name before filtering and emitting records.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:36:16 +01:00
Tudor Sitaru
752abd69a5 fix(tap-uk-ees): inject time_period from release slug when absent in CSV
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m8s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m37s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Older census (and other) files don't include a time_period column.
Derive it from the release slug (e.g. '2022-23' → '202223') and inject
it into records so the required Singer schema field is always present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:59:24 +01:00
Tudor Sitaru
570c2b689e fix(tap-uk-ees): handle plain list response from releases endpoint
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m45s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:47:14 +01:00
Tudor Sitaru
17617137ea fix(data-info): drop NaN years before converting to int
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 47s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:41:11 +01:00
Tudor Sitaru
9a1572ea20 feat(tap-uk-ees): fetch all historical releases, not just latest
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m42s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Add get_all_release_ids() to paginate /publications/{slug}/releases and
iterate over every release in get_records(). Add latest_only config flag
(default false) to restore single-release behaviour for daily runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:37:26 +01:00
f48faa1803 showing schools with no KS2 results
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 44s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-03-30 18:14:43 +01:00
6e5249aa1e refactor(phase): merge KS2+KS4 into fact_performance, fix all phase inconsistencies
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 50s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Root cause: the UNION ALL query in data_loader.py produced two rows per
all-through school per year (one KS2, one KS4), with drop_duplicates()
silently discarding the KS4 row. Fixes:

- New dbt mart `fact_performance`: FULL OUTER JOIN of fact_ks2_performance
  and fact_ks4_performance on (urn, year). One row per school per year.
  All-through schools have both KS2 and KS4 columns populated.
- data_loader.py: replace 175-line UNION ALL with a simple JOIN to
  fact_performance. No more duplicate rows or drop_duplicates needed.
- sync_typesense.py: single LATERAL JOIN to fact_performance instead of
  two separate KS2/KS4 joins.
- app.py: remove drop_duplicates (no longer needed); add PHASE_GROUPS
  constant so all-through/middle schools appear in primary and secondary
  filter results (were previously invisible to both); scope result_filters
  gender/admissions_policies to secondary schools only.
- HomeView.tsx: isSecondaryView is now majority-based (not "any secondary")
  and isMixedView shows both sort option sets for mixed result sets.
- school/[slug]/page.tsx: all-through schools route to SchoolDetailView
  (renders both SATs + GCSE sections) instead of SecondarySchoolDetailView
  (KS4-only). Dedicated SEO metadata for all-through schools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:07:30 +01:00
695a571c1f fix(filters): forward gender, admissions_policy, has_sixth_form to API
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
These params were present in the URL but never passed to fetchSchools on
the server side, so the backend never applied the filters. Also include
them in hasSearchParams so filter-only searches trigger a fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:45:09 +01:00
bd4e71dd30 feat(sort): persist sort order in URL as ?sort= param
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Sort was local state, lost on navigation. Now reads from
searchParams.get('sort') and pushes to URL on change.
'default' removes the param to keep URLs clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:28 +01:00
cd6a5d092c feat(map): make desktop map view taller using viewport height
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Replace fixed 480px height with calc(100vh - 280px) so the map
fills most of the viewport on any screen size, clamped between
520px and 800px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:12 +01:00
5aed055331 feat(map): add fullscreen button using browser Fullscreen API
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Button sits top-right of the map (matching Leaflet control style),
toggles expand/compress icon, and syncs state with Escape key via
the fullscreenchange event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:21:44 +01:00
d6a45b8e12 feat(map): fetch all schools for map view, add reference pin, cap radius at 5mi
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Remove 10-mile radius option; cap backend radius max at 5 miles
- Raise backend page_size max to 500 so map can fetch all schools in one call
- HomeView: when map view is active, fetch all schools within radius
  (page_size=500) instead of showing only the paginated first page;
  falls back to initial SSR schools while loading
- SchoolMap/LeafletMapInner: accept referencePoint prop and render a
  distinctive coral circle pin at the search postcode location

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:13:14 +01:00
daf24e4739 fix(search): fix load-more silently failing due to missing page_size param
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 47s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Load-more requests read URL params (postcode, radius, etc.) but page_size
is never in the URL — it's hardcoded in page.tsx. Without it the backend
received page_size=None, hit a TypeError on (page-1)*None, returned 500,
and the silent catch left the user stuck on page 1.

In a dense area (e.g. Wimbledon SW19) 50 schools fit within ~1.8 miles,
so page 1 never shows anything beyond that regardless of selected radius.

Fix:
- Backend: give page_size a safe default of 25 instead of None
- Frontend: explicitly pass initialSchools.page_size in load-more params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:08:15 +01:00
0c5bef34cf feat(ui): polish filter controls with pill styling and custom arrows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- controlsRow selects: pill shape (border-radius: 999px), appearance:none,
  custom SVG chevron arrow, hover/focus transitions
- advancedToggle: pill button with border, matches select height
- filterSelect (advanced panel): appearance:none, custom arrow, 8px radius,
  focus ring consistent with search input
- clearButton: pill shape matching other controls
- filters panel: separator line and spacing when expanded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:31:03 +01:00
5615458223 feat(ui): consolidate search/filter area into cleaner 2-row layout
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Before: 7 visual rows (search, radius row, phase row, advanced toggle,
teal location banner, results count, filter chips).
After: 2 rows in card + 1 unified results toolbar.

- FilterBar: merge radius, phase, and Advanced toggle into a single
  .controlsRow below the search bar; removed orphaned stacked rows
- HomeView: remove separate teal location banner; merge location info
  into results heading ("16 schools within 1.0 miles of SW196AR");
  move List/Map toggle inline with sort in one results header row
- Remove "Near postcode (Xmi)" chip (now redundant with heading)
- Sort select hidden in map view (sorting is meaningless there)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:46:38 +01:00
9c9528b51b fix(FilterBar): close fragment opened around phase filter and advanced section
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:21:11 +01:00
1009d7c976 fix(chart): format year as 2022/23 instead of 202223 on performance chart
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:17:04 +01:00
790b12a7f3 changes to order of display and text
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
2026-03-29 20:14:42 +01:00
8f4c052294 feat(filters): move phase filter out of advanced section to always-visible position
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m3s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Phase is a common filter (primary vs secondary) so it now appears
between the search form and the Advanced filters toggle rather than
being hidden inside the collapsible section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:33:45 +01:00
b7bff7bf6b feat(seo): static sitemap generation job via Airflow
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m29s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Backend builds sitemap.xml from school data at startup (in-memory)
- POST /api/admin/regenerate-sitemap refreshes it after data updates
- New Airflow DAG (sitemap_generate) runs Sundays 05:00 and calls the endpoint
- Next.js proxies /sitemap.xml to the backend; removes the slow dynamic sitemap.ts
- docker-compose passes BACKEND_URL + ADMIN_API_KEY to Airflow env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:15:41 +01:00
748891ab31 fix(detail): restore admissions cut-off note on secondary school page
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:07:52 +01:00
17b8873f0f fix(detail): add missing location map section to secondary school page
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:06:27 +01:00
15c0055687 refactor(detail): align secondary school page with primary — scroll-to-section nav
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Replace tab-based show/hide with always-visible sections and anchor
link navigation, matching the primary school detail page behaviour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:48:06 +01:00
6315f366c8 fix(build): single-brace JSX for schoolUrl, migrate images.domains to remotePatterns
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:15:06 +01:00
784febc162 feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
- URLs now /school/138267-school-name instead of /school/138267
- Bare URN URLs redirect to canonical slug (backward compat)
- Remove overflow-x:hidden that broke sticky tab nav on secondary pages
- ComparisonToast starts collapsed — user must click to open

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 12:41:28 +01:00
e2c700fcfc fix(ui): round admission percentages, fix mobile overflow on detail pages
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 11:22:13 +01:00
77a0f5b674 fix(detail): enrich secondary overview tab — show Ofsted grades, admissions, SEN
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 37s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The overview tab was sparse for schools without parent view data, showing
only 2 cards. Now shows:
- Individual Ofsted grades when no overall effectiveness (post-Sept 2024)
- Admissions summary card (PAN, applications, 1st choice rate)
- School context card (pupils, capacity, SEN support, EHCP)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:59:30 +01:00
63dfa22255 fix(detail): detect secondary schools by attainment_8_score, not just phase field
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Schools with phases like "All-through" or null phase but with GCSE data
were falling through to the primary SchoolDetailView, rendering only
partial content. Now checks yearly_data for attainment_8_score as well.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:12:07 +01:00
1d22877aec feat(ux): 8 UX improvements — simpler home, advanced filters, phase tabs, 4-line rows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 48s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
1. Simpler home page: only search box on landing, no filter dropdowns
2. Advanced filters: hidden behind toggle on results page, auto-open if active
3. Per-school phase rendering: each row renders based on its own data
4. Taller 4-line rows with context line (type, age range, denomination, gender)
5. Result-scoped filters: dropdown values reflect current search results
6. Fix blank filter values: exclude empty strings and "Not applicable"
7. Rankings: Primary/Secondary phase tabs with phase-specific metrics
8. Compare: Primary/Secondary tabs with school counts and phase metrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 08:57:06 +01:00
e8175561d5 updates for secondary schools
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m15s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-03-28 22:36:00 +00:00
f3a8ebdb4b fix(dbt): deduplicate int_ks4_with_lineage predecessor rows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
When multiple predecessor URNs exist for the same current school and
year, use DISTINCT ON to keep the one with the most pupils — matching
the same logic already in int_ks2_with_lineage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:58:50 +00:00
f0c76a1724 fix(dbt): fix stg_ees_ks4 breakdown filter: 'Total' not 'All pupils'
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 31s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The EES KS4 performance CSV uses breakdown_topic='Total' for the
all-pupils aggregate, not 'All pupils' as the model assumed. This
caused 0 rows to pass the filter despite 40k rows in raw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:35:00 +00:00
3e787b395f chore(pipeline): add EES KS4 tap diagnostic script
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 2m28s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:26:15 +00:00
3d1c4c61c9 fix(types): add missing phases field to Filters fallback in rankings page
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:03:03 +00:00
250d1f7c77 fix(tap-uk-idaci): add openpyxl dependency for Excel file parsing
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 49s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m2s
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:00:00 +00:00
5eff9af69c feat: add secondary school support with KS4 data and metric tooltips
Some checks failed
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Backend (FastAPI) (push) Has been cancelled
- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
  and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
  data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
  /api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
  English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
  a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
  tab hidden for secondary; admissions says Year 7 instead of Year 3;
  history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
  METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
  scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
  Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
  shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:59:40 +00:00
b0990e30ee fix(ui): retheme comparison toast to match site palette
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Switch from dark (#1a1612) to site's warm cream background
- Clear all button now visible as a text button with muted/coral hover
- Remove scroll bar: no max-height cap needed since 5 schools max
- Compare Now button uses coral accent to match primary CTAs
- School items use bg-secondary (beige) consistent with site cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:09:59 +00:00
1629a8f994 feat(pipeline): add DAGs for Parent View and IDACI deprivation
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
- school_data_monthly_parent_view: runs 1st of month, extracts Ofsted
  Parent View and builds fact_parent_view
- school_data_annual_idaci: manual trigger, extracts IDACI deprivation
  index and builds fact_deprivation

Both tables were missing, causing safe_query to fail and leave the
PostgreSQL transaction in an aborted state, silently killing all
subsequent supplementary data queries including fact_admissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:08:12 +00:00
55749bdfaf debug(backend): log safe_query exceptions and rollback on failure
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:00:27 +00:00
cd1c649d0f fix(frontend): format 6-digit EES academic year codes correctly
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
formatAcademicYear now handles both 4-digit (2023→2023/24) and 6-digit
EES codes (202526→2025/26). Applied to all year displays: SATs, phonics,
admissions, finances, and the yearly results table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 18:30:37 +00:00
7724fe3503 fix(stg_ofsted_inspections): correctly filter NULL string inspection dates
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The string 'NULL' is not SQL NULL, so the WHERE in the renamed CTE
passed those rows through. Filter on the raw value using nullif in the
CTE and on the computed date in the outer SELECT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 18:21:30 +00:00
1d56eebe87 fix(stg_ofsted_inspections): filter out rows with no inspection date
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Schools in the MI file that have never been inspected have a null
inspection_date after parsing. Exclude them — they are not inspection
records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:55:11 +00:00
10720400fd fix(stg_ofsted_inspections): parse DD/MM/YYYY date format from Ofsted CSV
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m3s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:34:34 +00:00
05cb22f1a5 fix(stg_ofsted_inspections): handle NULL strings from Ofsted CSV
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Use nullif+trim for date cast and safe_numeric for integer grades to
handle literal 'NULL' strings present in the new Report Card format CSV.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:23:46 +00:00
26aa3c2d70 fix(tap-uk-ofsted): fix header row detection matching 'urn' inside 'turn'
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The preamble row in Ofsted CSVs contains 'turn off all filters' which
matched 'urn' in line.lower(), so header_idx was set to 0 instead of
the real header row. Use a regex that matches URN only as a CSV field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:05:03 +00:00
e56a63c59c debug(tap-uk-ofsted): log CSV column names to diagnose 0-record extraction
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 31s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:47:32 +00:00
221923857d chore: remove integrator/kestra CI jobs, fix school website link protocol
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Remove build-integrator and build-kestra-init jobs from Gitea Actions
- Update trigger-deployment needs to only depend on remaining three builds
- Fix school website href to prepend https:// when protocol is missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:30:08 +00:00
62284e7a94 chore: remove Kestra and integrator legacy services
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 35s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Integrator (push) Failing after 30s
Build and Push Docker Images / Build Kestra Init (push) Failing after 29s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 30s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Migration to Airflow + Meltano pipeline is complete. Remove:
- kestra, kestra-init, integrator services from docker-compose.portainer.yml
- kestra_storage and supplementary_data volumes
- KESTRA_USER/KESTRA_PASSWORD env var references
- integrator/ directory (Kestra flows, scripts, Dockerfiles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:03:34 +00:00
668e234eb2 feat(census): add demographic columns to EES census tap and staging models
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m39s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
tap-uk-ees: EESCensusStream now declares 27 data columns (FSM %, EAL %,
ethnicity breakdowns, pupil counts) with clean Singer field names mapped
from the verbose CSV column names (e.g. '% of pupils known to be eligible
for free school meals' → fsm_pct) via a new _column_renames mechanism on
the base stream class.

stg_ees_census: materialised as table, applies safe_numeric to all
percentage/count columns, filters to numeric URNs.

int_pupil_chars_merged + fact_pupil_characteristics: pass all columns
through from staging (previously stubs with only 3 columns).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:07:48 +00:00
4b02ab3d8a feat: wire Typesense search into backend, fix sync performance data bug
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
sync_typesense.py:
- Fix query string replacement: was matching 'ST_X(l.geom) as lng' but
  QUERY_BASE uses 'l.longitude as lng' — KS2/KS4 lateral joins were
  silently dropped on every sync run

backend:
- Add typesense_url/typesense_api_key settings to config.py
- Add search_schools_typesense() to data_loader.py — queries Typesense
  'schools' alias, returns URNs in relevance order with typo tolerance;
  falls back to empty list if Typesense is unavailable
- /api/schools: replace pandas str.contains with Typesense search;
  results are filtered from the DataFrame and returned in relevance order;
  graceful fallback to substring match if Typesense is down

requirements.txt: add typesense==0.21.0, numpy==1.26.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 13:23:32 +00:00
5d8b319451 fix(dbt): stub rc_* columns as NULL in stg_ofsted_inspections
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m23s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
tap-uk-ofsted schema only declares OEIF columns; rc_* (Report Card)
columns were never emitted so they don't exist in raw.ofsted_inspections.
Replace column references with NULL::text until the actual CSV column
names for the post-Nov 2025 Report Card framework are confirmed and
added to the tap schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:50:58 +00:00
77f75fb6e5 fix(dbt): deduplicate predecessor KS2 rows and downgrade orphan test to warn
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- int_ks2_with_lineage: use DISTINCT ON (current_urn, year) in predecessor_ks2
  to handle schools with multiple predecessors that both have KS2 data for the
  same year (e.g. two schools that merged). Keeps the predecessor with most pupils.
- dbt_project.yml: downgrade assert_no_orphaned_facts to warn severity — the 10
  orphaned URNs are closed schools in EES data not present in GIAS/dim_school;
  they don't surface in the backend which joins on dim_school anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:16:36 +00:00
b41e6c250e fix(dbt): filter non-numeric URNs and trim whitespace in EES staging models
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Filter school_urn/time_period to '^[0-9]+$' to exclude "n/a" and other
  non-numeric values that caused integer cast failures in fact_admissions
- Add trim() to all school_urn/time_period casts to prevent whitespace
  variants producing duplicate urn+year rows in fact_ks2_performance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:00:30 +00:00
6e720feca4 perf(dbt): collapse stg_ees_ks2 to single-pass pivot
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Previous version scanned ees_ks2_attainment (1.2M rows) 5 times via
separate CTEs (all_pupils, gender_boys, gender_girls, disadv, not_disadv)
plus 5 LEFT JOINs. Rewritten as one GROUP BY with conditional aggregation
— single scan, no self-joins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:42:40 +00:00
ae9fd26eba perf(dbt): materialize stg_ees_ks2 and stg_ees_ks4 as tables
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
KS2 attainment has 1.2M rows in long format. As a view, the pivot was
re-executed inline for every downstream model (intermediate → fact),
causing fact_ks2_performance CREATE TABLE to run for 18+ minutes.

Materializing as tables means the pivot runs once during staging, and
downstream models read from a pre-computed ~16k-row result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:20:20 +00:00
33b395d2bd fix(dbt): apply safe_numeric macro to fix EES suppression code 'c' errors
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m14s
Build and Push Docker Images / Build Integrator (push) Successful in 58s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Replace nullif(col, 'z') casts with safe_numeric macro across KS2, KS4,
and admissions staging models. The regex-based macro treats any non-numeric
string (z, c, x, q, u, etc.) as NULL without needing an explicit list.

Also fix FSM_eligible_percent column quoting in stg_ees_admissions — target-
postgres stores mixed-case column names quoted, so unquoted references were
being folded to fsm_eligible_percent by PostgreSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:41:27 +00:00
8e8d1bd8c5 fix(ees-tap): filter out rows with null URN before emitting
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m47s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The admissions school-level file contains some rows with null school_urn
(LA/category aggregates that survive the geographic_level filter). These
cause a not-null constraint violation at target-postgres. Drop any row
where the URN column is null or empty before yielding records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:13:17 +00:00
c7357336e3 fix(ees-tap): fix BOM handling for admissions CSV
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Admissions file is UTF-8 with BOM, not Latin-1. Reading as latin-1
decoded the BOM bytes as '' which wasn't stripped. Change admissions
encoding to utf-8-sig (strips BOM automatically). Also update the manual
BOM strip fallback to handle the latin-1 decoded form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:03:17 +00:00
b8ecc5c58b fix(ees-tap): strip UTF-8 BOM from CSV column names
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m42s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Some DfE supporting-files CSVs have a UTF-8 BOM on the first column,
causing it to be named '\ufefftime_period' instead of 'time_period'.
This trips Singer schema validation ('time_period' is a required property).
Strip the BOM from all column names after read_csv.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:54:15 +00:00
f4f0257447 fix(ees-tap): add latin-1 encoding for census/admissions, default utf-8 for others
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 52s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m8s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
DfE supporting-files CSVs (spc_school_level_underlying_data, AppsandOffers
SchoolLevel) are Latin-1 encoded. Add _encoding class attribute to base
stream class and override to 'latin-1' for census and admissions streams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:41:40 +00:00
ca351e9d73 feat: migrate backend to marts schema, update EES tap for verified datasets
Pipeline:
- EES tap: split KS4 into performance + info streams, fix admissions filename
  (SchoolLevel keyword match), fix census filename (yearly suffix), remove
  phonics (no school-level data on EES), change endswith → in for matching
- stg_ees_ks4: rewrite to filter long-format data and extract Attainment 8,
  Progress 8, EBacc, English/Maths metrics; join KS4 info for context
- stg_ees_admissions: map real CSV columns (total_number_places_offered, etc.)
- stg_ees_census: update source reference, stub with TODO for data columns
- Remove stg_ees_phonics, fact_phonics (no school-level EES data)
- Add ees_ks4_performance + ees_ks4_info sources, remove ees_ks4 + ees_phonics
- Update int_ks4_with_lineage + fact_ks4_performance with new KS4 columns
- Annual EES DAG: remove stg_ees_phonics+ from selector

Backend:
- models.py: replace all models to point at marts.* tables with schema='marts'
  (DimSchool, DimLocation, KS2Performance, FactOfstedInspection, etc.)
- data_loader.py: rewrite load_school_data_as_dataframe() using raw SQL joining
  dim_school + dim_location + fact_ks2_performance; update get_supplementary_data()
- database.py: remove migration machinery, keep only connection setup
- app.py: remove check_and_migrate_if_needed, remove /api/admin/reimport-ks2
  endpoints (pipeline handles all imports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:29:27 +00:00
136 changed files with 6271 additions and 409327 deletions

View File

@@ -12,8 +12,6 @@ env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
INTEGRATOR_IMAGE_NAME: ${{ gitea.repository }}-integrator
KESTRA_INIT_IMAGE_NAME: ${{ gitea.repository }}-kestra-init
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
@@ -112,94 +110,6 @@ jobs:
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache,mode=max
build-integrator:
name: Build Integrator
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Integrator Docker image
id: meta-integrator
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.INTEGRATOR_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=integrator-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Integrator Docker image
uses: docker/build-push-action@v5
with:
context: ./integrator
file: ./integrator/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta-integrator.outputs.tags }}
labels: ${{ steps.meta-integrator.outputs.labels }}
build-kestra-init:
name: Build Kestra Init
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Kestra Init Docker image
id: meta-kestra-init
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.KESTRA_INIT_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=kestra-init-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Kestra Init Docker image
uses: docker/build-push-action@v5
with:
context: ./integrator
file: ./integrator/Dockerfile.init
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta-kestra-init.outputs.tags }}
labels: ${{ steps.meta-kestra-init.outputs.labels }}
build-pipeline:
name: Build Pipeline (Meltano + dbt + Airflow)
runs-on: ubuntu-latest
@@ -249,7 +159,7 @@ jobs:
trigger-deployment:
name: Trigger Portainer Update
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, build-integrator, build-kestra-init, build-pipeline]
needs: [build-backend, build-frontend, build-pipeline]
if: gitea.event_name != 'pull_request'
steps:
- name: Trigger Portainer stack update

View File

@@ -22,13 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY backend/ ./backend/
COPY frontend/ ./frontend/
COPY scripts/ ./scripts/
COPY data/ ./data/
# Expose the application port
EXPOSE 80
# Run the application (using module import)
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]

View File

@@ -1,6 +1,6 @@
"""
SchoolCompare.co.uk API
Serves primary school (KS2) performance data for comparing schools.
Serves primary and secondary school performance data for comparing schools.
Uses real data from UK Government Compare School Performance downloads.
"""
@@ -26,13 +26,91 @@ from .data_loader import (
load_school_data,
geocode_single_postcode,
get_supplementary_data,
search_schools_typesense,
)
from .data_loader import get_data_info as get_db_info
from .database import check_and_migrate_if_needed
from .migration import run_full_migration
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .utils import clean_for_json
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they include.
# All-through schools appear in both primary and secondary results.
PHASE_GROUPS: dict[str, set[str]] = {
"primary": {"primary", "middle deemed primary", "all-through"},
"secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"},
"all-through": {"all-through"},
}
BASE_URL = "https://schoolcompare.co.uk"
MAX_SLUG_LENGTH = 60
# In-memory sitemap cache
_sitemap_xml: str | None = None
def _slugify(text: str) -> str:
text = text.lower()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def _school_url(urn: int, school_name: str) -> str:
slug = _slugify(school_name)
if len(slug) > MAX_SLUG_LENGTH:
slug = slug[:MAX_SLUG_LENGTH].rstrip("-")
return f"/school/{urn}-{slug}"
def build_sitemap() -> str:
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
df = load_school_data()
static_urls = [
(BASE_URL + "/", "daily", "1.0"),
(BASE_URL + "/rankings", "weekly", "0.8"),
(BASE_URL + "/compare", "weekly", "0.8"),
]
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for url, freq, priority in static_urls:
lines.append(
f" <url><loc>{url}</loc>"
f"<changefreq>{freq}</changefreq>"
f"<priority>{priority}</priority></url>"
)
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
seen = set()
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
urn = int(row["urn"])
name = str(row["school_name"])
if urn in seen:
continue
seen.add(urn)
path = _school_url(urn, name)
lines.append(
f" <url><loc>{BASE_URL}{path}</loc>"
f"<changefreq>monthly</changefreq>"
f"<priority>0.6</priority></url>"
)
lines.append("</urlset>")
return "\n".join(lines)
def clean_filter_values(series: pd.Series) -> list[str]:
"""Return sorted unique values from a Series, excluding NaN and junk labels."""
return sorted(
v for v in series.dropna().unique().tolist()
if v not in EXCLUDED_FILTER_VALUES
)
# =============================================================================
# SECURITY MIDDLEWARE & HELPERS
@@ -138,26 +216,28 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan - startup and shutdown events."""
# Startup: check schema version and migrate if needed
print("Starting up: Checking database schema...")
check_and_migrate_if_needed()
print("Loading school data from database...")
global _sitemap_xml
print("Loading school data from marts...")
df = load_school_data()
if df.empty:
print("Warning: No data in database. Check CSV files in data/ folder.")
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
else:
print(f"Data loaded successfully: {len(df)} records.")
try:
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
print(f"Sitemap built: {n} URLs.")
except Exception as e:
print(f"Warning: sitemap build failed on startup: {e}")
yield # Application runs here
yield
# Shutdown: cleanup if needed
print("Shutting down...")
app = FastAPI(
title="SchoolCompare API",
description="API for comparing primary school (KS2) performance data - schoolcompare.co.uk",
description="API for comparing primary and secondary school performance data - schoolcompare.co.uk",
version="2.0.0",
lifespan=lifespan,
# Disable docs in production for security
@@ -219,21 +299,26 @@ async def get_schools(
None, description="Filter by local authority", max_length=100
),
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
page: int = Query(1, ge=1, le=1000, description="Page number"),
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
page_size: int = Query(25, ge=1, le=500, description="Results per page"),
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
):
"""
Get list of unique primary schools with pagination.
Get list of schools with pagination.
Returns paginated results with total count for efficient loading.
Supports location-based search using postcode.
Supports location-based search using postcode and phase filtering.
"""
# Sanitize inputs
search = sanitize_search_input(search)
local_authority = sanitize_search_input(local_authority)
school_type = sanitize_search_input(school_type)
phase = sanitize_search_input(phase)
postcode = validate_postcode(postcode)
df = load_school_data()
@@ -245,6 +330,11 @@ async def get_schools(
if page_size is None:
page_size = settings.default_page_size
# Schools with no performance data (special schools, PRUs, newly opened, etc.)
# have NULL year from the LEFT JOIN — keep them but skip the groupby/trend logic.
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
df = df[df["year"].notna()]
# Get unique schools (latest year data for each)
latest_year = df.groupby("urn")["year"].max().reset_index()
df_latest = df.merge(latest_year, on=["urn", "year"])
@@ -257,26 +347,59 @@ async def get_schools(
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
)
if "attainment_8_score" in df_prev.columns:
prev_rwm = prev_rwm.merge(
df_prev[["urn", "attainment_8_score"]].rename(
columns={"attainment_8_score": "prev_attainment_8_score"}
),
on="urn", how="outer"
)
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
# Merge back schools with no performance data
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
# in the correct phase(s) rather than being invisible to both filters.
if phase:
phase_lower = phase.lower().replace("_", "-")
allowed = PHASE_GROUPS.get(phase_lower)
if allowed:
df_latest = df_latest[df_latest["phase"].str.lower().isin(allowed)]
# Secondary-specific filters (after phase filter)
if gender:
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
if admissions_policy:
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
if has_sixth_form == "yes":
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
elif has_sixth_form == "no":
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
# Include key result metrics for display on cards
location_cols = ["latitude", "longitude"]
result_cols = [
"phase",
"year",
"rwm_expected_pct",
"rwm_high_pct",
"prev_rwm_expected_pct",
"prev_attainment_8_score",
"reading_expected_pct",
"writing_expected_pct",
"maths_expected_pct",
"total_pupils",
"attainment_8_score",
"english_maths_standard_pass_pct",
]
available_cols = [
c
for c in SCHOOL_COLUMNS + location_cols + result_cols
if c in df_latest.columns
]
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
schools_df = df_latest[available_cols]
# Location-based search (uses pre-geocoded data from database)
search_coords = None
@@ -321,14 +444,18 @@ async def get_schools(
# Apply filters
if search:
ts_urns = search_schools_typesense(search)
if ts_urns:
urn_order = {urn: i for i, urn in enumerate(ts_urns)}
schools_df = schools_df[schools_df["urn"].isin(set(ts_urns))].copy()
schools_df["_ts_rank"] = schools_df["urn"].map(urn_order)
schools_df = schools_df.sort_values("_ts_rank").drop(columns=["_ts_rank"])
else:
# Fallback: Typesense unavailable, use substring match
search_lower = search.lower()
mask = (
schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
)
mask = schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
if "address" in schools_df.columns:
mask = mask | schools_df["address"].str.lower().str.contains(
search_lower, na=False
)
mask = mask | schools_df["address"].str.lower().str.contains(search_lower, na=False)
schools_df = schools_df[mask]
if local_authority:
@@ -341,6 +468,18 @@ async def get_schools(
schools_df["school_type"].str.lower() == school_type.lower()
]
# Compute result-scoped filter values (before pagination).
# Gender and admissions are secondary-only filters — scope them to schools
# with KS4 data so they don't appear for purely primary result sets.
_sec_mask = schools_df["attainment_8_score"].notna() if "attainment_8_score" in schools_df.columns else pd.Series(False, index=schools_df.index)
result_filters = {
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
}
# Pagination
total = len(schools_df)
start_idx = (page - 1) * page_size
@@ -353,6 +492,7 @@ async def get_schools(
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"result_filters": result_filters,
"location_info": {
"postcode": postcode,
"radius": radius * 1.60934, # Convert miles to km for frontend display
@@ -366,7 +506,7 @@ async def get_schools(
@app.get("/api/schools/{urn}")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_school_details(request: Request, urn: int):
"""Get detailed KS2 data for a specific primary school across all years."""
"""Get detailed performance data for a specific school across all years."""
# Validate URN range (UK school URNs are 6 digits)
if not (100000 <= urn <= 999999):
raise HTTPException(status_code=400, detail="Invalid URN format")
@@ -408,7 +548,7 @@ async def get_school_details(request: Request, urn: int):
"age_range": latest.get("age_range", ""),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": "Primary",
"phase": latest.get("phase"),
# GIAS fields
"website": latest.get("website"),
"headteacher_name": latest.get("headteacher_name"),
@@ -435,7 +575,7 @@ async def compare_schools(
request: Request,
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
):
"""Compare multiple primary schools side by side."""
"""Compare multiple schools side by side."""
df = load_school_data()
if df.empty:
@@ -468,7 +608,11 @@ async def compare_schools(
"urn": urn,
"school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""),
"address": latest.get("address", ""),
"phase": latest.get("phase", ""),
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
},
"yearly_data": clean_for_json(school_data),
}
@@ -489,10 +633,85 @@ async def get_filter_options(request: Request):
"years": [],
}
# Phases: return values from data, ordered sensibly
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
return {
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
"years": sorted(df["year"].dropna().unique().tolist()),
"phases": phases,
"genders": genders,
"admissions_policies": admissions_policies,
}
@app.get("/api/la-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_la_averages(request: Request):
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
df = load_school_data()
if df.empty:
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
latest_year = int(df["year"].max())
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
@app.get("/api/national-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
"""
Compute national average for each metric from the latest data year.
Returns separate averages for primary (KS2) and secondary (KS4) schools.
Values are derived from the loaded DataFrame so they automatically
stay current when new data is loaded.
"""
df = load_school_data()
if df.empty:
return {"primary": {}, "secondary": {}}
latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
ks2_metrics = [
"rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress",
"overall_absence_pct", "persistent_absence_pct",
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct",
]
ks4_metrics = [
"attainment_8_score", "progress_8_score",
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
"ebacc_avg_score", "gcse_grade_91_pct",
]
def _means(sub_df, metric_list):
out = {}
for col in metric_list:
if col in sub_df.columns:
val = sub_df[col].dropna()
if len(val) > 0:
out[col] = round(float(val.mean()), 2)
return out
# Primary: schools where KS2 data is non-null
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
# Secondary: schools where KS4 data is non-null
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
return {
"year": latest_year,
"primary": _means(primary_df, ks2_metrics),
"secondary": _means(secondary_df, ks4_metrics),
}
@@ -500,7 +719,7 @@ async def get_filter_options(request: Request):
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_available_metrics(request: Request):
"""
Get list of available KS2 performance metrics for primary schools.
Get list of available performance metrics for schools.
This is the single source of truth for metric definitions.
Frontend should consume this to avoid duplication.
@@ -519,7 +738,7 @@ async def get_available_metrics(request: Request):
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_rankings(
request: Request,
metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by", max_length=50),
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query(
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
),
@@ -527,8 +746,11 @@ async def get_rankings(
local_authority: Optional[str] = Query(
None, description="Filter by local authority", max_length=100
),
phase: Optional[str] = Query(
None, description="Filter by phase: primary or secondary", max_length=20
),
):
"""Get primary school rankings by a specific KS2 metric."""
"""Get school rankings by a specific metric."""
# Sanitize local authority input
local_authority = sanitize_search_input(local_authority)
@@ -556,6 +778,12 @@ async def get_rankings(
if local_authority:
df = df[df["local_authority"].str.lower() == local_authority.lower()]
# Filter by phase
if phase == "primary" and "rwm_expected_pct" in df.columns:
df = df[df["rwm_expected_pct"].notna()]
elif phase == "secondary" and "attainment_8_score" in df.columns:
df = df[df["attainment_8_score"].notna()]
# Sort and rank (exclude rows with no data for this metric)
df = df.dropna(subset=[metric])
total = len(df)
@@ -585,7 +813,7 @@ async def get_data_info(request: Request):
if db_info["total_schools"] == 0:
return {
"status": "no_data",
"message": "No data in database. Run the migration script: python scripts/migrate_csv_to_db.py",
"message": "No data in marts. Run the annual EES pipeline to load KS2 data.",
"data_source": "PostgreSQL",
}
@@ -599,10 +827,10 @@ async def get_data_info(request: Request):
"data_source": "PostgreSQL",
}
years = [int(y) for y in sorted(df["year"].unique())]
years = [int(y) for y in sorted(df["year"].dropna().unique())]
schools_per_year = {
str(int(k)): int(v)
for k, v in df.groupby("year")["urn"].nunique().to_dict().items()
for k, v in df.dropna(subset=["year"]).groupby("year")["urn"].nunique().to_dict().items()
}
la_counts = {
str(k): int(v)
@@ -635,56 +863,6 @@ async def reload_data(
return {"status": "reloaded"}
_reimport_status: dict = {"running": False, "done": False, "error": None}
@app.post("/api/admin/reimport-ks2")
@limiter.limit("2/minute")
async def reimport_ks2(
request: Request,
geocode: bool = True,
_: bool = Depends(verify_admin_api_key)
):
"""
Start a full KS2 CSV migration in the background and return immediately.
Poll GET /api/admin/reimport-ks2/status to check progress.
Pass ?geocode=false to skip postcode → lat/lng resolution.
Requires X-API-Key header with valid admin API key.
"""
global _reimport_status
if _reimport_status["running"]:
return {"status": "already_running"}
_reimport_status = {"running": True, "done": False, "error": None}
def _run():
global _reimport_status
try:
success = run_full_migration(geocode=geocode)
if not success:
_reimport_status = {"running": False, "done": False, "error": "No CSV data found"}
return
clear_cache()
load_school_data()
_reimport_status = {"running": False, "done": True, "error": None}
except Exception as exc:
_reimport_status = {"running": False, "done": False, "error": str(exc)}
import threading
threading.Thread(target=_run, daemon=True).start()
return {"status": "started"}
@app.get("/api/admin/reimport-ks2/status")
async def reimport_ks2_status(
request: Request,
_: bool = Depends(verify_admin_api_key)
):
"""Poll this endpoint to check reimport progress."""
s = _reimport_status
if s["error"]:
raise HTTPException(status_code=500, detail=s["error"])
return {"running": s["running"], "done": s["done"]}
# =============================================================================
@@ -707,7 +885,26 @@ async def robots_txt():
@app.get("/sitemap.xml")
async def sitemap_xml():
"""Serve sitemap.xml for search engine indexing."""
return FileResponse(settings.frontend_dir / "sitemap.xml", media_type="application/xml")
global _sitemap_xml
if _sitemap_xml is None:
try:
_sitemap_xml = build_sitemap()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
return Response(content=_sitemap_xml, media_type="application/xml")
@app.post("/api/admin/regenerate-sitemap")
@limiter.limit("10/minute")
async def regenerate_sitemap(
request: Request,
_: bool = Depends(verify_admin_api_key),
):
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
global _sitemap_xml
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
return {"status": "ok", "urls": n}
# Mount static files directly (must be after all routes to avoid catching API calls)

View File

@@ -38,6 +38,10 @@ class Settings(BaseSettings):
rate_limit_burst: int = 10 # Allow burst of requests
max_request_size: int = 1024 * 1024 # 1MB max request size
# Typesense
typesense_url: str = "http://localhost:8108"
typesense_api_key: str = ""
# Analytics
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID

View File

@@ -1,545 +1,252 @@
"""
Data loading module that queries from PostgreSQL database.
Provides efficient queries with caching and lazy loading.
Note: School geocoding is handled by a separate cron job (scripts/geocode_schools.py).
Only user search postcodes are geocoded on-demand via geocode_single_postcode().
Data loading module — reads from marts.* tables built by dbt.
Provides efficient queries with caching.
"""
import pandas as pd
import numpy as np
from functools import lru_cache
from typing import Optional, Dict, Tuple, List
import requests
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import joinedload, Session
from sqlalchemy import text
from sqlalchemy.orm import Session
from .config import settings
from .database import SessionLocal, get_db_session
from .database import SessionLocal, engine
from .models import (
School, SchoolResult,
OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance,
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactParentView, FactAdmissions,
FactDeprivation, FactFinance,
)
from .schemas import SCHOOL_TYPE_MAP
# Cache for user search postcode geocoding (not for school data)
_postcode_cache: Dict[str, Tuple[float, float]] = {}
_typesense_client = None
def _get_typesense_client():
global _typesense_client
if _typesense_client is not None:
return _typesense_client
url = settings.typesense_url
key = settings.typesense_api_key
if not url or not key:
return None
try:
import typesense
host = url.split("//")[-1]
host_part, _, port_str = host.partition(":")
port = int(port_str) if port_str else 8108
_typesense_client = typesense.Client({
"nodes": [{"host": host_part, "port": str(port), "protocol": "http"}],
"api_key": key,
"connection_timeout_seconds": 2,
})
return _typesense_client
except Exception:
return None
def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
"""Search Typesense. Returns URNs in relevance order, or [] if unavailable."""
client = _get_typesense_client()
if client is None:
return []
try:
result = client.collections["schools"].documents.search({
"q": query,
"query_by": "school_name,local_authority,postcode",
"per_page": min(limit, 250),
"typo_tokens_threshold": 1,
})
return [int(h["document"]["urn"]) for h in result.get("hits", [])]
except Exception:
return []
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
"""Convert cryptic school type codes to user-friendly names."""
if not school_type:
return None
# Check if it's a code that needs mapping
code = school_type.strip().upper()
if code in SCHOOL_TYPE_MAP:
return SCHOOL_TYPE_MAP[code]
# Return original if already a friendly name or unknown code
return school_type
def get_school_type_codes_for_filter(school_type: str) -> List[str]:
"""Get all database codes that map to a given friendly name."""
if not school_type:
return []
school_type_lower = school_type.lower()
# Collect all codes that map to this friendly name
codes = []
for code, friendly_name in SCHOOL_TYPE_MAP.items():
if friendly_name.lower() == school_type_lower:
codes.append(code.lower())
# Also include the school_type itself (case-insensitive) in case it's stored as-is
codes.append(school_type_lower)
return codes
def geocode_single_postcode(postcode: str) -> Optional[Tuple[float, float]]:
"""Geocode a single postcode using postcodes.io API."""
if not postcode:
return None
postcode = postcode.strip().upper()
# Check cache first
if postcode in _postcode_cache:
return _postcode_cache[postcode]
try:
response = requests.get(
f'https://api.postcodes.io/postcodes/{postcode}',
timeout=10
f"https://api.postcodes.io/postcodes/{postcode}",
timeout=10,
)
if response.status_code == 200:
data = response.json()
if data.get('result'):
lat = data['result'].get('latitude')
lon = data['result'].get('longitude')
if data.get("result"):
lat = data["result"].get("latitude")
lon = data["result"].get("longitude")
if lat and lon:
_postcode_cache[postcode] = (lat, lon)
return (lat, lon)
except Exception:
pass
return None
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate the great circle distance between two points on Earth (in miles).
"""
"""Calculate great-circle distance between two points (miles)."""
from math import radians, cos, sin, asin, sqrt
# Convert to radians
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
# Haversine formula
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
# Earth's radius in miles
r = 3956
return c * r
return 2 * asin(sqrt(a)) * 3956
# =============================================================================
# DATABASE QUERY FUNCTIONS
# MAIN DATA LOAD — joins dim_school + dim_location + fact_performance
# fact_performance is a merged KS2+KS4 table (one row per URN per year).
# All-through schools have both KS2 and KS4 columns populated in the same row.
# =============================================================================
def get_db():
"""Get a database session."""
return SessionLocal()
_MAIN_QUERY = text("""
SELECT
s.urn,
s.school_name,
s.phase,
s.school_type,
s.academy_trust_name AS trust_name,
s.academy_trust_uid AS trust_uid,
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
s.admissions_policy,
s.capacity,
s.headteacher_name,
s.website,
s.ofsted_grade,
s.ofsted_date,
s.ofsted_framework,
l.local_authority_name AS local_authority,
l.local_authority_code,
l.address_line1 AS address1,
l.address_line2 AS address2,
l.town,
l.postcode,
l.latitude,
l.longitude,
p.year,
p.source_urn,
p.total_pupils,
p.eligible_pupils,
-- KS2 columns (NULL for pure secondary schools)
p.rwm_expected_pct,
p.rwm_high_pct,
p.reading_expected_pct,
p.reading_high_pct,
p.reading_avg_score,
p.reading_progress,
p.writing_expected_pct,
p.writing_high_pct,
p.writing_progress,
p.maths_expected_pct,
p.maths_high_pct,
p.maths_avg_score,
p.maths_progress,
p.gps_expected_pct,
p.gps_high_pct,
p.gps_avg_score,
p.science_expected_pct,
p.reading_absence_pct,
p.writing_absence_pct,
p.maths_absence_pct,
p.gps_absence_pct,
p.science_absence_pct,
p.rwm_expected_boys_pct,
p.rwm_high_boys_pct,
p.rwm_expected_girls_pct,
p.rwm_high_girls_pct,
p.rwm_expected_disadvantaged_pct,
p.rwm_expected_non_disadvantaged_pct,
p.disadvantaged_gap,
p.disadvantaged_pct,
p.eal_pct,
p.stability_pct,
-- KS4 columns (NULL for pure primary schools)
p.attainment_8_score,
p.progress_8_score,
p.progress_8_lower_ci,
p.progress_8_upper_ci,
p.progress_8_english,
p.progress_8_maths,
p.progress_8_ebacc,
p.progress_8_open,
p.english_maths_strong_pass_pct,
p.english_maths_standard_pass_pct,
p.ebacc_entry_pct,
p.ebacc_strong_pass_pct,
p.ebacc_standard_pass_pct,
p.ebacc_avg_score,
p.gcse_grade_91_pct,
p.prior_attainment_avg,
-- SEN (coalesced KS2+KS4 in fact_performance)
p.sen_support_pct,
p.sen_ehcp_pct
FROM marts.dim_school s
JOIN marts.dim_location l ON s.urn = l.urn
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
ORDER BY s.school_name, p.year
""")
def get_available_years(db: Session = None) -> List[int]:
"""Get list of available years in the database."""
close_db = db is None
if db is None:
db = get_db()
def load_school_data_as_dataframe() -> pd.DataFrame:
"""Load all school + KS2 data as a pandas DataFrame."""
try:
result = db.query(SchoolResult.year).distinct().order_by(SchoolResult.year).all()
return [r[0] for r in result]
finally:
if close_db:
db.close()
def get_available_local_authorities(db: Session = None) -> List[str]:
"""Get list of available local authorities."""
close_db = db is None
if db is None:
db = get_db()
try:
result = db.query(School.local_authority)\
.filter(School.local_authority.isnot(None))\
.distinct()\
.order_by(School.local_authority)\
.all()
return [r[0] for r in result if r[0]]
finally:
if close_db:
db.close()
def get_available_school_types(db: Session = None) -> List[str]:
"""Get list of available school types (normalized to user-friendly names)."""
close_db = db is None
if db is None:
db = get_db()
try:
result = db.query(School.school_type)\
.filter(School.school_type.isnot(None))\
.distinct()\
.all()
# Normalize codes to friendly names and deduplicate
normalized = set()
for r in result:
if r[0]:
friendly_name = normalize_school_type(r[0])
if friendly_name:
normalized.add(friendly_name)
return sorted(normalized)
finally:
if close_db:
db.close()
def get_schools_count(db: Session = None) -> int:
"""Get total number of schools."""
close_db = db is None
if db is None:
db = get_db()
try:
return db.query(School).count()
finally:
if close_db:
db.close()
def get_schools(
db: Session,
search: Optional[str] = None,
local_authority: Optional[str] = None,
school_type: Optional[str] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[School], int]:
"""
Get paginated list of schools with optional filters.
Returns (schools, total_count).
"""
query = db.query(School)
# Apply filters
if search:
search_lower = f"%{search.lower()}%"
query = query.filter(
or_(
func.lower(School.school_name).like(search_lower),
func.lower(School.postcode).like(search_lower),
func.lower(School.town).like(search_lower),
)
)
if local_authority:
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
if school_type:
# Filter by all codes that map to this friendly name
type_codes = get_school_type_codes_for_filter(school_type)
if type_codes:
query = query.filter(func.lower(School.school_type).in_(type_codes))
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * page_size
schools = query.order_by(School.school_name).offset(offset).limit(page_size).all()
return schools, total
def get_schools_near_location(
db: Session,
latitude: float,
longitude: float,
radius_miles: float = 5.0,
search: Optional[str] = None,
local_authority: Optional[str] = None,
school_type: Optional[str] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Tuple[School, float]], int]:
"""
Get schools near a location, sorted by distance.
Returns list of (school, distance) tuples and total count.
"""
# Get all schools with coordinates
query = db.query(School).filter(
School.latitude.isnot(None),
School.longitude.isnot(None)
)
# Apply text filters
if search:
search_lower = f"%{search.lower()}%"
query = query.filter(
or_(
func.lower(School.school_name).like(search_lower),
func.lower(School.postcode).like(search_lower),
func.lower(School.town).like(search_lower),
)
)
if local_authority:
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
if school_type:
# Filter by all codes that map to this friendly name
type_codes = get_school_type_codes_for_filter(school_type)
if type_codes:
query = query.filter(func.lower(School.school_type).in_(type_codes))
# Get all matching schools and calculate distances
all_schools = query.all()
schools_with_distance = []
for school in all_schools:
if school.latitude and school.longitude:
dist = haversine_distance(latitude, longitude, school.latitude, school.longitude)
if dist <= radius_miles:
schools_with_distance.append((school, dist))
# Sort by distance
schools_with_distance.sort(key=lambda x: x[1])
total = len(schools_with_distance)
# Paginate
offset = (page - 1) * page_size
paginated = schools_with_distance[offset:offset + page_size]
return paginated, total
def get_school_by_urn(db: Session, urn: int) -> Optional[School]:
"""Get a single school by URN."""
return db.query(School).filter(School.urn == urn).first()
def get_school_results(
db: Session,
urn: int,
years: Optional[List[int]] = None
) -> List[SchoolResult]:
"""Get all results for a school, optionally filtered by years."""
query = db.query(SchoolResult)\
.join(School)\
.filter(School.urn == urn)\
.order_by(SchoolResult.year)
if years:
query = query.filter(SchoolResult.year.in_(years))
return query.all()
def get_rankings(
db: Session,
metric: str,
year: int,
local_authority: Optional[str] = None,
limit: int = 20,
ascending: bool = False,
) -> List[Tuple[School, SchoolResult]]:
"""
Get school rankings for a specific metric and year.
Returns list of (school, result) tuples.
"""
# Build the query
query = db.query(School, SchoolResult)\
.join(SchoolResult)\
.filter(SchoolResult.year == year)
# Filter by local authority
if local_authority:
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
# Get the metric column
metric_column = getattr(SchoolResult, metric, None)
if metric_column is None:
return []
# Filter out nulls and order
query = query.filter(metric_column.isnot(None))
if ascending:
query = query.order_by(metric_column.asc())
else:
query = query.order_by(metric_column.desc())
return query.limit(limit).all()
def get_data_info(db: Session = None) -> dict:
"""Get information about the data in the database."""
close_db = db is None
if db is None:
db = get_db()
try:
school_count = db.query(School).count()
result_count = db.query(SchoolResult).count()
years = get_available_years(db)
local_authorities = get_available_local_authorities(db)
return {
"total_schools": school_count,
"total_results": result_count,
"years_available": years,
"local_authorities_count": len(local_authorities),
"data_source": "PostgreSQL",
}
finally:
if close_db:
db.close()
def school_to_dict(school: School, include_results: bool = False) -> dict:
"""Convert a School model to dictionary."""
data = {
"urn": school.urn,
"school_name": school.school_name,
"local_authority": school.local_authority,
"school_type": normalize_school_type(school.school_type),
"address": school.address,
"town": school.town,
"postcode": school.postcode,
"latitude": school.latitude,
"longitude": school.longitude,
# GIAS fields
"website": school.website,
"headteacher_name": school.headteacher_name,
"capacity": school.capacity,
"trust_name": school.trust_name,
"gender": school.gender,
}
if include_results and school.results:
data["results"] = [result_to_dict(r) for r in school.results]
return data
def result_to_dict(result: SchoolResult) -> dict:
"""Convert a SchoolResult model to dictionary."""
return {
"year": result.year,
"total_pupils": result.total_pupils,
"eligible_pupils": result.eligible_pupils,
# Expected Standard
"rwm_expected_pct": result.rwm_expected_pct,
"reading_expected_pct": result.reading_expected_pct,
"writing_expected_pct": result.writing_expected_pct,
"maths_expected_pct": result.maths_expected_pct,
"gps_expected_pct": result.gps_expected_pct,
"science_expected_pct": result.science_expected_pct,
# Higher Standard
"rwm_high_pct": result.rwm_high_pct,
"reading_high_pct": result.reading_high_pct,
"writing_high_pct": result.writing_high_pct,
"maths_high_pct": result.maths_high_pct,
"gps_high_pct": result.gps_high_pct,
# Progress
"reading_progress": result.reading_progress,
"writing_progress": result.writing_progress,
"maths_progress": result.maths_progress,
# Averages
"reading_avg_score": result.reading_avg_score,
"maths_avg_score": result.maths_avg_score,
"gps_avg_score": result.gps_avg_score,
# Context
"disadvantaged_pct": result.disadvantaged_pct,
"eal_pct": result.eal_pct,
"sen_support_pct": result.sen_support_pct,
"sen_ehcp_pct": result.sen_ehcp_pct,
"stability_pct": result.stability_pct,
# Gender
"rwm_expected_boys_pct": result.rwm_expected_boys_pct,
"rwm_expected_girls_pct": result.rwm_expected_girls_pct,
"rwm_high_boys_pct": result.rwm_high_boys_pct,
"rwm_high_girls_pct": result.rwm_high_girls_pct,
# Disadvantaged
"rwm_expected_disadvantaged_pct": result.rwm_expected_disadvantaged_pct,
"rwm_expected_non_disadvantaged_pct": result.rwm_expected_non_disadvantaged_pct,
"disadvantaged_gap": result.disadvantaged_gap,
# 3-Year
"rwm_expected_3yr_pct": result.rwm_expected_3yr_pct,
"reading_avg_3yr": result.reading_avg_3yr,
"maths_avg_3yr": result.maths_avg_3yr,
}
# =============================================================================
# LEGACY COMPATIBILITY - DataFrame-based functions
# =============================================================================
def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
"""
Load all school data as a pandas DataFrame.
For compatibility with existing code that expects DataFrames.
"""
close_db = db is None
if db is None:
db = get_db()
try:
# Query all schools with their results
schools = db.query(School).options(joinedload(School.results)).all()
# Load Ofsted data into a lookup dict (urn → grade, date)
ofsted_lookup: Dict[int, dict] = {}
try:
ofsted_rows = db.query(
OfstedInspection.urn,
OfstedInspection.overall_effectiveness,
OfstedInspection.inspection_date,
).all()
for o in ofsted_rows:
ofsted_lookup[o.urn] = {
"ofsted_grade": o.overall_effectiveness,
"ofsted_date": o.inspection_date.isoformat() if o.inspection_date else None,
}
except Exception:
pass # Table may not exist yet on first run
rows = []
for school in schools:
ofsted = ofsted_lookup.get(school.urn, {})
for result in school.results:
row = {
"urn": school.urn,
"school_name": school.school_name,
"local_authority": school.local_authority,
"school_type": normalize_school_type(school.school_type),
"address": school.address,
"town": school.town,
"postcode": school.postcode,
"latitude": school.latitude,
"longitude": school.longitude,
# GIAS fields
"website": school.website,
"headteacher_name": school.headteacher_name,
"capacity": school.capacity,
"trust_name": school.trust_name,
"gender": school.gender,
# Ofsted (for list view)
"ofsted_grade": ofsted.get("ofsted_grade"),
"ofsted_date": ofsted.get("ofsted_date"),
**result_to_dict(result)
}
rows.append(row)
if rows:
return pd.DataFrame(rows)
df = pd.read_sql(_MAIN_QUERY, engine)
except Exception as exc:
print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame()
finally:
if close_db:
db.close()
if df.empty:
return df
# Build address string
df["address"] = df.apply(
lambda r: ", ".join(
p for p in [r.get("address1"), r.get("address2"), r.get("town"), r.get("postcode")]
if p and str(p) != "None"
),
axis=1,
)
# Normalize school type
df["school_type"] = df["school_type"].apply(normalize_school_type)
return df
# Cache for DataFrame (legacy compatibility)
# Cache for DataFrame
_df_cache: Optional[pd.DataFrame] = None
def load_school_data() -> pd.DataFrame:
"""
Legacy function to load school data as DataFrame.
Uses caching for performance.
"""
"""Load school data with caching."""
global _df_cache
if _df_cache is not None:
return _df_cache
print("Loading school data from database...")
print("Loading school data from marts...")
_df_cache = load_school_data_as_dataframe()
if not _df_cache.empty:
print(f"Total records loaded: {len(_df_cache)}")
print(f"Unique schools: {_df_cache['urn'].nunique()}")
print(f"Years: {sorted(_df_cache['year'].unique())}")
print(f"Years: {sorted(_df_cache['year'].dropna().unique())}")
else:
print("No data found in database")
print("No data found in marts (EES data may not have been loaded yet)")
return _df_cache
@@ -549,44 +256,111 @@ def clear_cache():
_df_cache = None
# =============================================================================
# METADATA QUERIES
# =============================================================================
def get_available_years(db: Session = None) -> List[int]:
close_db = db is None
if db is None:
db = SessionLocal()
try:
result = db.query(KS2Performance.year).distinct().order_by(KS2Performance.year).all()
return [r[0] for r in result]
except Exception:
return []
finally:
if close_db:
db.close()
def get_available_local_authorities(db: Session = None) -> List[str]:
close_db = db is None
if db is None:
db = SessionLocal()
try:
result = (
db.query(DimLocation.local_authority_name)
.filter(DimLocation.local_authority_name.isnot(None))
.distinct()
.order_by(DimLocation.local_authority_name)
.all()
)
return [r[0] for r in result if r[0]]
except Exception:
return []
finally:
if close_db:
db.close()
def get_schools_count(db: Session = None) -> int:
close_db = db is None
if db is None:
db = SessionLocal()
try:
return db.query(DimSchool).count()
except Exception:
return 0
finally:
if close_db:
db.close()
def get_data_info(db: Session = None) -> dict:
close_db = db is None
if db is None:
db = SessionLocal()
try:
school_count = get_schools_count(db)
years = get_available_years(db)
local_authorities = get_available_local_authorities(db)
return {
"total_schools": school_count,
"years_available": years,
"local_authorities_count": len(local_authorities),
"data_source": "PostgreSQL (marts)",
}
finally:
if close_db:
db.close()
# =============================================================================
# SUPPLEMENTARY DATA — per-school detail page
# =============================================================================
def get_supplementary_data(db: Session, urn: int) -> dict:
"""
Fetch all supplementary data for a single school URN.
Returns a dict with keys: ofsted, parent_view, census, admissions, sen_detail,
phonics, deprivation, finance. Values are dicts or None.
"""
"""Fetch all supplementary data for a single school URN."""
result = {}
def safe_query(model, pk_field, latest_year_field=None):
def safe_query(model, pk_field, latest_field=None):
try:
if latest_year_field:
row = (
db.query(model)
.filter(getattr(model, pk_field) == urn)
.order_by(getattr(model, latest_year_field).desc())
.first()
)
else:
row = db.query(model).filter(getattr(model, pk_field) == urn).first()
return row
except Exception:
q = db.query(model).filter(getattr(model, pk_field) == urn)
if latest_field:
q = q.order_by(getattr(model, latest_field).desc())
return q.first()
except Exception as e:
import logging
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
db.rollback()
return None
# Ofsted inspection
o = safe_query(OfstedInspection, "urn")
result["ofsted"] = {
# Latest Ofsted inspection
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
result["ofsted"] = (
{
"framework": o.framework,
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
"inspection_type": o.inspection_type,
# OEIF fields (old framework)
"overall_effectiveness": o.overall_effectiveness,
"quality_of_education": o.quality_of_education,
"behaviour_attitudes": o.behaviour_attitudes,
"personal_development": o.personal_development,
"leadership_management": o.leadership_management,
"early_years_provision": o.early_years_provision,
"previous_overall": o.previous_overall,
# Report Card fields (new framework, from Nov 2025)
"sixth_form_provision": o.sixth_form_provision,
"previous_overall": None, # Not available in new schema
"rc_safeguarding_met": o.rc_safeguarding_met,
"rc_inclusion": o.rc_inclusion,
"rc_curriculum_teaching": o.rc_curriculum_teaching,
@@ -596,11 +370,16 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"rc_leadership_governance": o.rc_leadership_governance,
"rc_early_years": o.rc_early_years,
"rc_sixth_form": o.rc_sixth_form,
} if o else None
"report_url": o.report_url,
}
if o
else None
)
# Parent View
pv = safe_query(OfstedParentView, "urn")
result["parent_view"] = {
pv = safe_query(FactParentView, "urn")
result["parent_view"] = (
{
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
"total_responses": pv.total_responses,
"q_happy_pct": pv.q_happy_pct,
@@ -616,69 +395,62 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"q_leadership_pct": pv.q_leadership_pct,
"q_wellbeing_pct": pv.q_wellbeing_pct,
"q_recommend_pct": pv.q_recommend_pct,
"q_sen_pct": pv.q_sen_pct,
} if pv else None
}
if pv
else None
)
# School Census (latest year)
c = safe_query(SchoolCensus, "urn", "year")
result["census"] = {
"year": c.year,
"class_size_avg": c.class_size_avg,
"ethnicity_white_pct": c.ethnicity_white_pct,
"ethnicity_asian_pct": c.ethnicity_asian_pct,
"ethnicity_black_pct": c.ethnicity_black_pct,
"ethnicity_mixed_pct": c.ethnicity_mixed_pct,
"ethnicity_other_pct": c.ethnicity_other_pct,
} if c else None
# Census (fact_pupil_characteristics — minimal until census columns are verified)
result["census"] = None
# Admissions (latest year)
a = safe_query(SchoolAdmissions, "urn", "year")
result["admissions"] = {
a = safe_query(FactAdmissions, "urn", "year")
result["admissions"] = (
{
"year": a.year,
"school_phase": a.school_phase,
"published_admission_number": a.published_admission_number,
"total_applications": a.total_applications,
"first_preference_offers_pct": a.first_preference_offers_pct,
"first_preference_applications": a.first_preference_applications,
"first_preference_offers": a.first_preference_offers,
"first_preference_offer_pct": a.first_preference_offer_pct,
"oversubscribed": a.oversubscribed,
} if a else None
}
if a
else None
)
# SEN Detail (latest year)
s = safe_query(SenDetail, "urn", "year")
result["sen_detail"] = {
"year": s.year,
"primary_need_speech_pct": s.primary_need_speech_pct,
"primary_need_autism_pct": s.primary_need_autism_pct,
"primary_need_mld_pct": s.primary_need_mld_pct,
"primary_need_spld_pct": s.primary_need_spld_pct,
"primary_need_semh_pct": s.primary_need_semh_pct,
"primary_need_physical_pct": s.primary_need_physical_pct,
"primary_need_other_pct": s.primary_need_other_pct,
} if s else None
# SEN detail — not available in current marts
result["sen_detail"] = None
# Phonics (latest year)
ph = safe_query(Phonics, "urn", "year")
result["phonics"] = {
"year": ph.year,
"year1_phonics_pct": ph.year1_phonics_pct,
"year2_phonics_pct": ph.year2_phonics_pct,
} if ph else None
# Phonics — no school-level data on EES
result["phonics"] = None
# Deprivation
d = safe_query(SchoolDeprivation, "urn")
result["deprivation"] = {
d = safe_query(FactDeprivation, "urn")
result["deprivation"] = (
{
"lsoa_code": d.lsoa_code,
"idaci_score": d.idaci_score,
"idaci_decile": d.idaci_decile,
} if d else None
}
if d
else None
)
# Finance (latest year)
f = safe_query(SchoolFinance, "urn", "year")
result["finance"] = {
f = safe_query(FactFinance, "urn", "year")
result["finance"] = (
{
"year": f.year,
"per_pupil_spend": f.per_pupil_spend,
"staff_cost_pct": f.staff_cost_pct,
"teacher_cost_pct": f.teacher_cost_pct,
"support_staff_cost_pct": f.support_staff_cost_pct,
"premises_cost_pct": f.premises_cost_pct,
} if f else None
}
if f
else None
)
return result

View File

@@ -1,36 +1,30 @@
"""
Database connection setup using SQLAlchemy.
The schema is managed by dbt — the backend only reads from marts.* tables.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker, declarative_base
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
# Create engine
engine = create_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # Verify connections before use
echo=False, # Set to True for SQL debugging
pool_pre_ping=True,
echo=False,
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
def get_db():
"""
Dependency for FastAPI routes to get a database session.
"""
"""Dependency for FastAPI routes."""
db = SessionLocal()
try:
yield db
@@ -40,10 +34,7 @@ def get_db():
@contextmanager
def get_db_session():
"""
Context manager for database sessions.
Use in non-FastAPI contexts (scripts, etc).
"""
"""Context manager for non-FastAPI contexts."""
db = SessionLocal()
try:
yield db
@@ -53,95 +44,3 @@ def get_db_session():
raise
finally:
db.close()
def init_db():
"""
Initialize database - create all tables.
"""
Base.metadata.create_all(bind=engine)
def drop_db():
"""
Drop all tables - use with caution!
"""
Base.metadata.drop_all(bind=engine)
def get_db_schema_version() -> Optional[int]:
"""
Get the current schema version from the database.
Returns None if table doesn't exist or no version is set.
"""
from .models import SchemaVersion # Import here to avoid circular imports
# Check if schema_version table exists
inspector = inspect(engine)
if "schema_version" not in inspector.get_table_names():
return None
try:
with get_db_session() as db:
row = db.query(SchemaVersion).first()
return row.version if row else None
except Exception:
return None
def set_db_schema_version(version: int):
"""
Set/update the schema version in the database.
Creates the row if it doesn't exist.
"""
from .models import SchemaVersion
with get_db_session() as db:
row = db.query(SchemaVersion).first()
if row:
row.version = version
row.migrated_at = datetime.utcnow()
else:
db.add(SchemaVersion(id=1, version=version, migrated_at=datetime.utcnow()))
def check_and_migrate_if_needed():
"""
Check schema version and run migration if needed.
Called during application startup.
"""
from .version import SCHEMA_VERSION
from .migration import run_full_migration
db_version = get_db_schema_version()
if db_version == SCHEMA_VERSION:
print(f"Schema version {SCHEMA_VERSION} matches. Fast startup.")
# Still ensure tables exist (they should if version matches)
init_db()
return
if db_version is None:
print(f"No schema version found. Running initial migration (v{SCHEMA_VERSION})...")
else:
print(f"Schema mismatch: DB has v{db_version}, code expects v{SCHEMA_VERSION}")
print("Running full migration...")
try:
# Set schema version BEFORE migration so a crash mid-migration
# doesn't cause an infinite re-migration loop on every restart.
init_db()
set_db_schema_version(SCHEMA_VERSION)
success = run_full_migration(geocode=False)
if success:
print(f"Migration complete. Schema version {SCHEMA_VERSION}.")
else:
print("Warning: Migration completed but no data was imported.")
except Exception as e:
print(f"FATAL: Migration failed: {e}")
print("Application cannot start. Please check database and CSV files.")
raise

View File

@@ -1,408 +1,216 @@
"""
SQLAlchemy database models for school data.
Normalized schema with separate tables for schools and yearly results.
SQLAlchemy models — all tables live in the marts schema, built by dbt.
Read-only: the pipeline writes to these tables; the backend only reads.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, Boolean, Date, Text, Index
from sqlalchemy import (
Column, Integer, String, Float, ForeignKey, Index, UniqueConstraint,
Text, Boolean, DateTime, Date
)
from sqlalchemy.orm import relationship
from .database import Base
MARTS = {"schema": "marts"}
class School(Base):
"""
Core school information - relatively static data.
"""
__tablename__ = "schools"
id = Column(Integer, primary_key=True, autoincrement=True)
urn = Column(Integer, unique=True, nullable=False, index=True)
class DimSchool(Base):
"""Canonical school dimension — one row per active URN."""
__tablename__ = "dim_school"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
school_name = Column(String(255), nullable=False)
local_authority = Column(String(100))
local_authority_code = Column(Integer)
phase = Column(String(100))
school_type = Column(String(100))
school_type_code = Column(String(10))
religious_denomination = Column(String(100))
academy_trust_name = Column(String(255))
academy_trust_uid = Column(String(20))
religious_character = Column(String(100))
gender = Column(String(20))
age_range = Column(String(20))
capacity = Column(Integer)
total_pupils = Column(Integer)
headteacher_name = Column(String(200))
website = Column(String(255))
telephone = Column(String(30))
status = Column(String(50))
nursery_provision = Column(Boolean)
admissions_policy = Column(String(50))
# Denormalised Ofsted summary (updated by monthly pipeline)
ofsted_grade = Column(Integer)
ofsted_date = Column(Date)
ofsted_framework = Column(String(20))
# Address
address1 = Column(String(255))
address2 = Column(String(255))
class DimLocation(Base):
"""School location — address, lat/lng from easting/northing (BNG→WGS84)."""
__tablename__ = "dim_location"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
address_line1 = Column(String(255))
address_line2 = Column(String(255))
town = Column(String(100))
postcode = Column(String(20), index=True)
# Geocoding (cached)
county = Column(String(100))
postcode = Column(String(20))
local_authority_code = Column(Integer)
local_authority_name = Column(String(100))
parliamentary_constituency = Column(String(100))
urban_rural = Column(String(50))
easting = Column(Integer)
northing = Column(Integer)
latitude = Column(Float)
longitude = Column(Float)
# GIAS enrichment fields
website = Column(String(255))
headteacher_name = Column(String(200))
capacity = Column(Integer)
trust_name = Column(String(255))
trust_uid = Column(String(20))
gender = Column(String(20)) # Mixed / Girls / Boys
nursery_provision = Column(Boolean)
# Relationships
results = relationship("SchoolResult", back_populates="school", cascade="all, delete-orphan")
def __repr__(self):
return f"<School(urn={self.urn}, name='{self.school_name}')>"
@property
def address(self):
"""Combine address fields into single string."""
parts = [self.address1, self.address2, self.town, self.postcode]
return ", ".join(p for p in parts if p)
# geom is a PostGIS geometry — not mapped to SQLAlchemy (accessed via raw SQL)
class SchoolResult(Base):
"""
Yearly KS2 results for a school.
Each school can have multiple years of results.
"""
__tablename__ = "school_results"
class KS2Performance(Base):
"""KS2 attainment — one row per URN per year (includes predecessor data)."""
__tablename__ = "fact_ks2_performance"
__table_args__ = (
Index("ix_ks2_urn_year", "urn", "year"),
MARTS,
)
id = Column(Integer, primary_key=True, autoincrement=True)
school_id = Column(Integer, ForeignKey("schools.id", ondelete="CASCADE"), nullable=False)
year = Column(Integer, nullable=False, index=True)
# Pupil numbers
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
source_urn = Column(Integer)
total_pupils = Column(Integer)
eligible_pupils = Column(Integer)
# Core KS2 metrics - Expected Standard
# Core attainment
rwm_expected_pct = Column(Float)
reading_expected_pct = Column(Float)
writing_expected_pct = Column(Float)
maths_expected_pct = Column(Float)
gps_expected_pct = Column(Float)
science_expected_pct = Column(Float)
# Higher Standard
rwm_high_pct = Column(Float)
reading_expected_pct = Column(Float)
reading_high_pct = Column(Float)
writing_high_pct = Column(Float)
maths_high_pct = Column(Float)
gps_high_pct = Column(Float)
# Progress Scores
reading_progress = Column(Float)
writing_progress = Column(Float)
maths_progress = Column(Float)
# Average Scores
reading_avg_score = Column(Float)
reading_progress = Column(Float)
writing_expected_pct = Column(Float)
writing_high_pct = Column(Float)
writing_progress = Column(Float)
maths_expected_pct = Column(Float)
maths_high_pct = Column(Float)
maths_avg_score = Column(Float)
maths_progress = Column(Float)
gps_expected_pct = Column(Float)
gps_high_pct = Column(Float)
gps_avg_score = Column(Float)
# School Context
science_expected_pct = Column(Float)
# Absence
reading_absence_pct = Column(Float)
writing_absence_pct = Column(Float)
maths_absence_pct = Column(Float)
gps_absence_pct = Column(Float)
science_absence_pct = Column(Float)
# Gender
rwm_expected_boys_pct = Column(Float)
rwm_high_boys_pct = Column(Float)
rwm_expected_girls_pct = Column(Float)
rwm_high_girls_pct = Column(Float)
# Disadvantaged
rwm_expected_disadvantaged_pct = Column(Float)
rwm_expected_non_disadvantaged_pct = Column(Float)
disadvantaged_gap = Column(Float)
# Context
disadvantaged_pct = Column(Float)
eal_pct = Column(Float)
sen_support_pct = Column(Float)
sen_ehcp_pct = Column(Float)
stability_pct = Column(Float)
# Pupil Absence from Tests
reading_absence_pct = Column(Float)
gps_absence_pct = Column(Float)
maths_absence_pct = Column(Float)
writing_absence_pct = Column(Float)
science_absence_pct = Column(Float)
# Gender Breakdown
rwm_expected_boys_pct = Column(Float)
rwm_expected_girls_pct = Column(Float)
rwm_high_boys_pct = Column(Float)
rwm_high_girls_pct = Column(Float)
# Disadvantaged Performance
rwm_expected_disadvantaged_pct = Column(Float)
rwm_expected_non_disadvantaged_pct = Column(Float)
disadvantaged_gap = Column(Float)
# 3-Year Averages
rwm_expected_3yr_pct = Column(Float)
reading_avg_3yr = Column(Float)
maths_avg_3yr = Column(Float)
# Relationship
school = relationship("School", back_populates="results")
# Constraints
class FactOfstedInspection(Base):
"""Full Ofsted inspection history — one row per inspection."""
__tablename__ = "fact_ofsted_inspection"
__table_args__ = (
UniqueConstraint('school_id', 'year', name='uq_school_year'),
Index('ix_school_results_school_year', 'school_id', 'year'),
Index("ix_ofsted_urn_date", "urn", "inspection_date"),
MARTS,
)
def __repr__(self):
return f"<SchoolResult(school_id={self.school_id}, year={self.year})>"
class SchemaVersion(Base):
"""
Tracks database schema version for automatic migrations.
Single-row table that stores the current schema version.
"""
__tablename__ = "schema_version"
id = Column(Integer, primary_key=True, default=1)
version = Column(Integer, nullable=False)
migrated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<SchemaVersion(version={self.version}, migrated_at={self.migrated_at})>"
# ---------------------------------------------------------------------------
# Supplementary data tables (populated by the Kestra data integrator)
# ---------------------------------------------------------------------------
class OfstedInspection(Base):
"""Latest Ofsted inspection judgement per school."""
__tablename__ = "ofsted_inspections"
urn = Column(Integer, primary_key=True)
inspection_date = Column(Date)
publication_date = Column(Date)
inspection_type = Column(String(100)) # Section 5 / Section 8 etc.
# Which inspection framework was used: 'OEIF' or 'ReportCard'
inspection_date = Column(Date, primary_key=True)
inspection_type = Column(String(100))
framework = Column(String(20))
# --- OEIF grades (old framework, pre-Nov 2025) ---
# 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate
overall_effectiveness = Column(Integer)
quality_of_education = Column(Integer)
behaviour_attitudes = Column(Integer)
personal_development = Column(Integer)
leadership_management = Column(Integer)
early_years_provision = Column(Integer) # nullable — not all schools
previous_overall = Column(Integer) # for trend display
# --- Report Card grades (new framework, from Nov 2025) ---
# 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement
rc_safeguarding_met = Column(Boolean) # True=Met, False=Not met
early_years_provision = Column(Integer)
sixth_form_provision = Column(Integer)
rc_safeguarding_met = Column(Boolean)
rc_inclusion = Column(Integer)
rc_curriculum_teaching = Column(Integer)
rc_achievement = Column(Integer)
rc_attendance_behaviour = Column(Integer)
rc_personal_development = Column(Integer)
rc_leadership_governance = Column(Integer)
rc_early_years = Column(Integer) # nullable — not all schools
rc_sixth_form = Column(Integer) # nullable — secondary only
def __repr__(self):
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
rc_early_years = Column(Integer)
rc_sixth_form = Column(Integer)
report_url = Column(Text)
class OfstedParentView(Base):
"""Ofsted Parent View survey — latest per school. 14 questions, % saying Yes."""
__tablename__ = "ofsted_parent_view"
class FactParentView(Base):
"""Ofsted Parent View survey — latest per school."""
__tablename__ = "fact_parent_view"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
survey_date = Column(Date)
total_responses = Column(Integer)
q_happy_pct = Column(Float) # My child is happy at this school
q_safe_pct = Column(Float) # My child feels safe at this school
q_bullying_pct = Column(Float) # School deals with bullying well
q_communication_pct = Column(Float) # School keeps me informed
q_progress_pct = Column(Float) # My child does well / good progress
q_teaching_pct = Column(Float) # Teaching is good
q_information_pct = Column(Float) # I receive valuable info about progress
q_curriculum_pct = Column(Float) # Broad range of subjects taught
q_future_pct = Column(Float) # Prepares child well for the future
q_leadership_pct = Column(Float) # Led and managed effectively
q_wellbeing_pct = Column(Float) # Supports wider personal development
q_behaviour_pct = Column(Float) # Pupils are well behaved
q_recommend_pct = Column(Float) # I would recommend this school
q_sen_pct = Column(Float) # Good information about child's SEN (where applicable)
def __repr__(self):
return f"<OfstedParentView(urn={self.urn}, responses={self.total_responses})>"
q_happy_pct = Column(Float)
q_safe_pct = Column(Float)
q_behaviour_pct = Column(Float)
q_bullying_pct = Column(Float)
q_communication_pct = Column(Float)
q_progress_pct = Column(Float)
q_teaching_pct = Column(Float)
q_information_pct = Column(Float)
q_curriculum_pct = Column(Float)
q_future_pct = Column(Float)
q_leadership_pct = Column(Float)
q_wellbeing_pct = Column(Float)
q_recommend_pct = Column(Float)
class SchoolCensus(Base):
"""Annual school census snapshot — class sizes and ethnicity breakdown."""
__tablename__ = "school_census"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
class_size_avg = Column(Float)
ethnicity_white_pct = Column(Float)
ethnicity_asian_pct = Column(Float)
ethnicity_black_pct = Column(Float)
ethnicity_mixed_pct = Column(Float)
ethnicity_other_pct = Column(Float)
class FactAdmissions(Base):
"""School admissions — one row per URN per year."""
__tablename__ = "fact_admissions"
__table_args__ = (
Index('ix_school_census_urn_year', 'urn', 'year'),
Index("ix_admissions_urn_year", "urn", "year"),
MARTS,
)
def __repr__(self):
return f"<SchoolCensus(urn={self.urn}, year={self.year})>"
class SchoolAdmissions(Base):
"""Annual admissions statistics per school."""
__tablename__ = "school_admissions"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
published_admission_number = Column(Integer) # PAN
school_phase = Column(String(50))
published_admission_number = Column(Integer)
total_applications = Column(Integer)
first_preference_offers_pct = Column(Float) # % receiving 1st choice
first_preference_applications = Column(Integer)
first_preference_offers = Column(Integer)
first_preference_offer_pct = Column(Float)
oversubscribed = Column(Boolean)
__table_args__ = (
Index('ix_school_admissions_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<SchoolAdmissions(urn={self.urn}, year={self.year})>"
admissions_policy = Column(String(100))
class SenDetail(Base):
"""SEN primary need type breakdown — more granular than school_results context fields."""
__tablename__ = "sen_detail"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
primary_need_speech_pct = Column(Float) # SLCN
primary_need_autism_pct = Column(Float) # ASD
primary_need_mld_pct = Column(Float) # Moderate learning difficulty
primary_need_spld_pct = Column(Float) # Specific learning difficulty (dyslexia etc.)
primary_need_semh_pct = Column(Float) # Social, emotional, mental health
primary_need_physical_pct = Column(Float) # Physical/sensory
primary_need_other_pct = Column(Float)
__table_args__ = (
Index('ix_sen_detail_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<SenDetail(urn={self.urn}, year={self.year})>"
class Phonics(Base):
"""Phonics Screening Check pass rates."""
__tablename__ = "phonics"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
year1_phonics_pct = Column(Float) # % reaching expected standard in Year 1
year2_phonics_pct = Column(Float) # % reaching standard in Year 2 (re-takers)
__table_args__ = (
Index('ix_phonics_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<Phonics(urn={self.urn}, year={self.year})>"
class SchoolDeprivation(Base):
"""IDACI deprivation index — derived via postcode → LSOA lookup."""
__tablename__ = "school_deprivation"
class FactDeprivation(Base):
"""IDACI deprivation index — one row per URN."""
__tablename__ = "fact_deprivation"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
lsoa_code = Column(String(20))
idaci_score = Column(Float) # 01, higher = more deprived
idaci_decile = Column(Integer) # 1 = most deprived, 10 = least deprived
def __repr__(self):
return f"<SchoolDeprivation(urn={self.urn}, decile={self.idaci_decile})>"
idaci_score = Column(Float)
idaci_decile = Column(Integer)
class SchoolFinance(Base):
"""FBIT financial benchmarking data."""
__tablename__ = "school_finance"
class FactFinance(Base):
"""FBIT financial benchmarking — one row per URN per year."""
__tablename__ = "fact_finance"
__table_args__ = (
Index("ix_finance_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
per_pupil_spend = Column(Float) # £ total expenditure per pupil
staff_cost_pct = Column(Float) # % of budget on all staff
teacher_cost_pct = Column(Float) # % on teachers specifically
per_pupil_spend = Column(Float)
staff_cost_pct = Column(Float)
teacher_cost_pct = Column(Float)
support_staff_cost_pct = Column(Float)
premises_cost_pct = Column(Float)
__table_args__ = (
Index('ix_school_finance_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<SchoolFinance(urn={self.urn}, year={self.year})>"
# Mapping from CSV columns to model fields
SCHOOL_FIELD_MAPPING = {
'urn': 'urn',
'school_name': 'school_name',
'local_authority': 'local_authority',
'local_authority_code': 'local_authority_code',
'school_type': 'school_type',
'school_type_code': 'school_type_code',
'religious_denomination': 'religious_denomination',
'age_range': 'age_range',
'address1': 'address1',
'address2': 'address2',
'town': 'town',
'postcode': 'postcode',
}
RESULT_FIELD_MAPPING = {
'year': 'year',
'total_pupils': 'total_pupils',
'eligible_pupils': 'eligible_pupils',
# Expected Standard
'rwm_expected_pct': 'rwm_expected_pct',
'reading_expected_pct': 'reading_expected_pct',
'writing_expected_pct': 'writing_expected_pct',
'maths_expected_pct': 'maths_expected_pct',
'gps_expected_pct': 'gps_expected_pct',
'science_expected_pct': 'science_expected_pct',
# Higher Standard
'rwm_high_pct': 'rwm_high_pct',
'reading_high_pct': 'reading_high_pct',
'writing_high_pct': 'writing_high_pct',
'maths_high_pct': 'maths_high_pct',
'gps_high_pct': 'gps_high_pct',
# Progress
'reading_progress': 'reading_progress',
'writing_progress': 'writing_progress',
'maths_progress': 'maths_progress',
# Averages
'reading_avg_score': 'reading_avg_score',
'maths_avg_score': 'maths_avg_score',
'gps_avg_score': 'gps_avg_score',
# Context
'disadvantaged_pct': 'disadvantaged_pct',
'eal_pct': 'eal_pct',
'sen_support_pct': 'sen_support_pct',
'sen_ehcp_pct': 'sen_ehcp_pct',
'stability_pct': 'stability_pct',
# Absence
'reading_absence_pct': 'reading_absence_pct',
'gps_absence_pct': 'gps_absence_pct',
'maths_absence_pct': 'maths_absence_pct',
'writing_absence_pct': 'writing_absence_pct',
'science_absence_pct': 'science_absence_pct',
# Gender
'rwm_expected_boys_pct': 'rwm_expected_boys_pct',
'rwm_expected_girls_pct': 'rwm_expected_girls_pct',
'rwm_high_boys_pct': 'rwm_high_boys_pct',
'rwm_high_girls_pct': 'rwm_high_girls_pct',
# Disadvantaged
'rwm_expected_disadvantaged_pct': 'rwm_expected_disadvantaged_pct',
'rwm_expected_non_disadvantaged_pct': 'rwm_expected_non_disadvantaged_pct',
'disadvantaged_gap': 'disadvantaged_gap',
# 3-Year
'rwm_expected_3yr_pct': 'rwm_expected_3yr_pct',
'reading_avg_3yr': 'reading_avg_3yr',
'maths_avg_3yr': 'maths_avg_3yr',
}

View File

@@ -401,6 +401,70 @@ METRIC_DEFINITIONS = {
"type": "score",
"category": "trends",
},
# ── GCSE Performance (KS4) ────────────────────────────────────────────
"attainment_8_score": {
"name": "Attainment 8",
"short_name": "Att 8",
"description": "Average grade across a pupil's best 8 GCSEs including English and Maths",
"type": "score",
"category": "gcse",
},
"progress_8_score": {
"name": "Progress 8",
"short_name": "P8",
"description": "Progress from KS2 baseline to GCSE relative to similar pupils nationally (0 = national average)",
"type": "score",
"category": "gcse",
},
"english_maths_standard_pass_pct": {
"name": "English & Maths Grade 4+",
"short_name": "E&M 4+",
"description": "% of pupils achieving grade 4 (standard pass) or above in both English and Maths",
"type": "percentage",
"category": "gcse",
},
"english_maths_strong_pass_pct": {
"name": "English & Maths Grade 5+",
"short_name": "E&M 5+",
"description": "% of pupils achieving grade 5 (strong pass) or above in both English and Maths",
"type": "percentage",
"category": "gcse",
},
"ebacc_entry_pct": {
"name": "EBacc Entry %",
"short_name": "EBacc Entry",
"description": "% of pupils entered for the English Baccalaureate (English, Maths, Sciences, Languages, Humanities)",
"type": "percentage",
"category": "gcse",
},
"ebacc_standard_pass_pct": {
"name": "EBacc Grade 4+",
"short_name": "EBacc 4+",
"description": "% of pupils achieving grade 4+ across all EBacc subjects",
"type": "percentage",
"category": "gcse",
},
"ebacc_strong_pass_pct": {
"name": "EBacc Grade 5+",
"short_name": "EBacc 5+",
"description": "% of pupils achieving grade 5+ across all EBacc subjects",
"type": "percentage",
"category": "gcse",
},
"ebacc_avg_score": {
"name": "EBacc Average Score",
"short_name": "EBacc Avg",
"description": "Average points score across EBacc subjects",
"type": "score",
"category": "gcse",
},
"gcse_grade_91_pct": {
"name": "GCSE Grade 91 %",
"short_name": "GCSE 91",
"description": "% of GCSE entries achieving a grade 9 to 1",
"type": "percentage",
"category": "gcse",
},
}
# Ranking columns to include in rankings response
@@ -456,6 +520,16 @@ RANKING_COLUMNS = [
"rwm_expected_3yr_pct",
"reading_avg_3yr",
"maths_avg_3yr",
# GCSE (KS4)
"attainment_8_score",
"progress_8_score",
"english_maths_standard_pass_pct",
"english_maths_strong_pass_pct",
"ebacc_entry_pct",
"ebacc_standard_pass_pct",
"ebacc_strong_pass_pct",
"ebacc_avg_score",
"gcse_grade_91_pct",
]
# School listing columns
@@ -469,6 +543,10 @@ SCHOOL_COLUMNS = [
"postcode",
"religious_denomination",
"age_range",
"gender",
"admissions_policy",
"ofsted_grade",
"ofsted_date",
"latitude",
"longitude",
]

BIN
data/.DS_Store vendored

Binary file not shown.

View File

@@ -1,3 +0,0 @@
# Place your CSV data files here
# Download from: https://www.compare-school-performance.service.gov.uk/download-data

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,24 +0,0 @@
Field Number,Field Reference,Field Name,Values,Data Format,LA level field?,National level field?
1,URN,School Unique Reference Number,999999,I6,No,No
2,LA,LA number,999,I3,Yes,No
3,ESTAB,ESTAB number,9999,I4,No,No
4,SCHOOLTYPE,Type of school,String,,No,No
5,NOR,Total number of pupils on roll,9999 or NA,,Yes,Yes
6,NORG,Number of girls on roll,9999 or NA,,Yes,Yes
7,NORB,Number of boys on roll,9999 or NA,,Yes,Yes
8,PNORG,Percentage of girls on roll,99.9 or NA,,Yes,Yes
9,PNORB,Percentage of boys on roll,99.9 or NA,,Yes,Yes
10,TSENELSE,Number of eligible pupils with an EHC plan,9999 or NA,A4,Yes,Yes
11,PSENELSE,Percentage of eligible pupils with an EHC plan,99.9 or NA,A4,Yes,Yes
12,TSENELK,Number of eligible pupils with SEN support,9999 or NA,A4,Yes,Yes
13,PSENELK,Percentage of eligible pupils with SEN support,99.9 or NA,A4,Yes,Yes
14,NUMEAL,No. pupils where English not first language,9999 or NA,A4,Yes,Yes
15,NUMENGFL,No. pupils with English first language,9999 or NA,A4,Yes,Yes
16,NUMUNCFL,No. pupils where first language is unclassified,9999 or NA,A4,Yes,Yes
17,PNUMEAL,% pupils where English not first language,99.9 or NA,A4,Yes,Yes
18,PNUMENGFL,% pupils with English first language,99.9 or NA,A4,Yes,Yes
19,PNUMUNCFL,% pupils where first language is unclassified,99.9 or NA,A4,Yes,Yes
20,NUMFSM,No. pupils eligible for free school meals,9999 or NA,A4,Yes,Yes
21,NUMFSMEVER,Number of pupils eligible for FSM at any time during the past 6 years,9999 or NA,A6,Yes,Yes
22,NORFSMEVER,Total pupils for FSMEver,9999 or NA,,Yes,Yes
23,PNUMFSMEVER,Percentage of pupils eligible for FSM at any time during the past 6 years,99.9 or NA,A4,Yes,Yes
1 Field Number Field Reference Field Name Values Data Format LA level field? National level field?
2 1 URN School Unique Reference Number 999999 I6 No No
3 2 LA LA number 999 I3 Yes No
4 3 ESTAB ESTAB number 9999 I4 No No
5 4 SCHOOLTYPE Type of school String No No
6 5 NOR Total number of pupils on roll 9999 or NA Yes Yes
7 6 NORG Number of girls on roll 9999 or NA Yes Yes
8 7 NORB Number of boys on roll 9999 or NA Yes Yes
9 8 PNORG Percentage of girls on roll 99.9 or NA Yes Yes
10 9 PNORB Percentage of boys on roll 99.9 or NA Yes Yes
11 10 TSENELSE Number of eligible pupils with an EHC plan 9999 or NA A4 Yes Yes
12 11 PSENELSE Percentage of eligible pupils with an EHC plan 99.9 or NA A4 Yes Yes
13 12 TSENELK Number of eligible pupils with SEN support 9999 or NA A4 Yes Yes
14 13 PSENELK Percentage of eligible pupils with SEN support 99.9 or NA A4 Yes Yes
15 14 NUMEAL No. pupils where English not first language 9999 or NA A4 Yes Yes
16 15 NUMENGFL No. pupils with English first language 9999 or NA A4 Yes Yes
17 16 NUMUNCFL No. pupils where first language is unclassified 9999 or NA A4 Yes Yes
18 17 PNUMEAL % pupils where English not first language 99.9 or NA A4 Yes Yes
19 18 PNUMENGFL % pupils with English first language 99.9 or NA A4 Yes Yes
20 19 PNUMUNCFL % pupils where first language is unclassified 99.9 or NA A4 Yes Yes
21 20 NUMFSM No. pupils eligible for free school meals 9999 or NA A4 Yes Yes
22 21 NUMFSMEVER Number of pupils eligible for FSM at any time during the past 6 years 9999 or NA A6 Yes Yes
23 22 NORFSMEVER Total pupils for FSMEver 9999 or NA Yes Yes
24 23 PNUMFSMEVER Percentage of pupils eligible for FSM at any time during the past 6 years 99.9 or NA A4 Yes Yes

View File

@@ -1,312 +0,0 @@
Column,Field Name,Label/Description
1,RECTYPE,Record type
2,AlphaIND,Alphabetic index
3,LEA,Local authority number
4,ESTAB,Establishment number
5,URN,School unique reference number
6,SCHNAME,School/Local authority name
7,ADDRESS1,School address (1)
8,ADDRESS2,School address (2)
9,ADDRESS3,School address (3)
10,TOWN,School town
11,PCODE,School postcode
12,TELNUM,School telephone number
13,PCON_CODE,School parliamentary constituency code
14,PCON_NAME,School parliamentary constituency name
15,URN_AC,Converter academy: URN
16,SCHNAME_AC,Converter academy: name
17,OPEN_AC,Converter academy: open date
18,NFTYPE,School type
19,ICLOSE,Closed Flag
20,RELDENOM,Religious denomination
21,AGERANGE,Age range
22,TAB15,School published in secondary school (key stage 4) performance tables
23,TAB1618,School published in school and college (key stage 5) performance tables
24,TOTPUPS,Total number of pupils (including part-time pupils)
25,TPUPYEAR,Number of pupils aged 11
26,TELIG,Published eligible pupil number
27,BELIG,Eligible boys on school roll at time of tests
28,GELIG,Eligible girls on school roll at time of tests
29,PBELIG,Percentage of eligible boys on school roll at time of tests
30,PGELIG,Percentage of eligible girls on school roll at time of tests
31,TKS1AVERAGE,Cohort level key stage 1 average points score [not populated in 2025]
32,TKS1GROUP_L,Number of pupils in cohort with low KS1 attainment [not populated in 2025]
33,PTKS1GROUP_L,Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
34,TKS1GROUP_M,Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
35,PTKS1GROUP_M,Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
36,TKS1GROUP_H,Number of pupils in cohort high KS1 attainment [not populated in 2025]
37,PTKS1GROUP_H,Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
38,TKS1GROUP_NA,No. of pupils in KS1 group not calculable [not populated in 2025]
39,PTKS1GROUP_NA,Percentage of pupils in KS1group not calculable [not populated in 2025]
40,TFSM6CLA1A,Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
41,PTFSM6CLA1A,Percentage of key stage 2 disadvantaged pupils
42,TNotFSM6CLA1A,Number of key stage 2 pupils who are not disadvantaged
43,PTNotFSM6CLA1A,Percentage of key stage 2 pupils who are not disadvantaged
44,TEALGRP2,Number of eligible pupils with English as additional language (EAL)
45,PTEALGRP2,Percentage of eligible pupils with English as additional language (EAL)
46,TMOBN,Number of eligible pupils classified as non-mobile
47,PTMOBN,Percentage of eligible pupils classified as non-mobile
48,PTRWM_EXP,"Percentage of pupils reaching the expected standard in reading, writing and maths"
49,PTRWM_HIGH,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
50,READPROG,Reading progress measure [not populated in 2025]
51,READPROG_LOWER,Reading progress measure - lower confidence limit [not populated in 2025]
52,READPROG_UPPER,Reading progress measure - upper confidence limit [not populated in 2025]
53,READCOV,Reading progress measure - coverage [not populated in 2025]
54,WRITPROG,Writing progress measure [not populated in 2025]
55,WRITPROG_LOWER,Writing progress measure - lower confidence limit [not populated in 2025]
56,WRITPROG_UPPER,Writing progress measure - upper confidence limit [not populated in 2025]
57,WRITCOV,Writing progress measure - coverage [not populated in 2025]
58,MATPROG,Maths progress measure [not populated in 2025]
59,MATPROG_LOWER,Maths progress measure - lower confidence limit [not populated in 2025]
60,MATPROG_UPPER,Maths progress measure - upper confidence limit [not populated in 2025]
61,MATCOV,Maths progress measure - coverage [not populated in 2025]
62,PTREAD_EXP,Percentage of pupils reaching the expected standard in reading
63,PTREAD_HIGH,Percentage of pupils achieving a high score in reading
64,PTREAD_AT,Percentage of pupils absent from or not able to access the test in reading
65,READ_AVERAGE,Average scaled score in reading
66,PTGPS_EXP,"Percentage of pupils reaching the expected standard in grammar, punctuation and spelling"
67,PTGPS_HIGH,"Percentage of pupils achieving a high score in grammar, punctuation and spelling"
68,PTGPS_AT,"Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling"
69,GPS_AVERAGE,"Average scaled score in grammar, punctuation and spelling"
70,PTMAT_EXP,Percentage of pupils reaching the expected standard in maths
71,PTMAT_HIGH,Percentage of pupils achieving a high score in maths
72,PTMAT_AT,Percentage of pupils absent from or not able to access the test in maths
73,MAT_AVERAGE,Average scaled score in maths
74,PTWRITTA_EXP,Percentage of pupils reaching the expected standard in writing
75,PTWRITTA_HIGH,Percentage of pupils working at greater depth within the expected standard in writing
76,PTWRITTA_WTS,Percentage of pupils working towards the expected standard in writing
77,PTWRITTA_AD,Percentage of pupils absent or disapplied in writing TA
78,PTSCITA_EXP,Percentage of pupils reaching the expected standard in science TA
79,PTSCITA_AD,Percentage of pupils absent or disapplied in science TA
80,PTRWM_EXP_B,"Percentage of boys reaching the expected standard in reading, writing and maths"
81,PTRWM_EXP_G,"Percentage of girls reaching the expected standard in reading, writing and maths"
82,PTRWM_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
83,PTRWM_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
84,PTRWM_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
85,PTRWM_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths"
86,PTRWM_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths"
87,DIFFN_RWM_EXP,"Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths "
88,PTRWM_EXP_EAL,"Percentage of EAL pupils reaching the expected standard in reading, writing and maths"
89,PTRWM_EXP_MOBN,"Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths"
90,PTRWM_HIGH_B,Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
91,PTRWM_HIGH_G,"Percentage of girls reaching the HIGHected standard in reading, writing and maths"
92,PTRWM_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
93,PTRWM_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
94,PTRWM_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
95,PTRWM_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
96,PTRWM_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
97,DIFFN_RWM_HIGH,"Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths "
98,PTRWM_HIGH_EAL,Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
99,PTRWM_HIGH_MOBN,Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
100,READPROG_B,Reading progress measure for boys [not populated in 2025]
101,READPROG_B_LOWER,Reading progress measure for boys - lower confidence limit [not populated in 2025]
102,READPROG_B_UPPER,Reading progress measure for boys - upper confidence limit [not populated in 2025]
103,READPROG_G,Reading progress measure for girls [not populated in 2025]
104,READPROG_G_LOWER,Reading progress measure for girls - lower confidence limit [not populated in 2025]
105,READPROG_G_UPPER,Reading progress measure for girls - upper confidence limit [not populated in 2025]
106,READPROG_L,Reading progress measure for pupils with low prior attainment [not populated in 2025]
107,READPROG_L_LOWER,Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
108,READPROG_L_UPPER,Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
109,READPROG_M,Reading progress measure for pupils with medium prior attainment [not populated in 2025]
110,READPROG_M_LOWER,Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
111,READPROG_M_UPPER,Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
112,READPROG_H,Reading progress measure for pupils with high prior attainment [not populated in 2025]
113,READPROG_H_LOWER,Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
114,READPROG_H_UPPER,Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
115,READPROG_FSM6CLA1A,Reading progress measure for disadvantaged pupils [not populated in 2025]
116,READPROG_FSM6CLA1A_LOWER,Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
117,READPROG_FSM6CLA1A_UPPER,Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
118,READPROG_NotFSM6CLA1A,Reading progress measure for non-disadvantaged pupils [not populated in 2025]
119,READPROG_NotFSM6CLA1A_LOWER,Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
120,READPROG_NotFSM6CLA1A_UPPER,Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
121,DIFFN_READPROG,Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
122,READPROG_EAL,Reading progress measure for EAL pupils [not populated in 2025]
123,READPROG_EAL_LOWER,Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
124,READPROG_EAL_UPPER,Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
125,READPROG_MOBN,Reading progress measure for non-mobile pupils [not populated in 2025]
126,READPROG_MOBN_LOWER,Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
127,READPROG_MOBN_UPPER,Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
128,WRITPROG_B,Writing progress measure for boys [not populated in 2025]
129,WRITPROG_B_LOWER,Writing progress measure for boys - lower confidence limit [not populated in 2025]
130,WRITPROG_B_UPPER,Writing progress measure for boys - upper confidence limit [not populated in 2025]
131,WRITPROG_G,Writing progress measure for girls [not populated in 2025]
132,WRITPROG_G_LOWER,Writing progress measure for girls - lower confidence limit [not populated in 2025]
133,WRITPROG_G_UPPER,Writing progress measure for girls - upper confidence limit [not populated in 2025]
134,WRITPROG_L,Writing progress measure for pupils with low prior attainment [not populated in 2025]
135,WRITPROG_L_LOWER,Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
136,WRITPROG_L_UPPER,Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
137,WRITPROG_M,Writing progress measure for pupils with medium prior attainment [not populated in 2025]
138,WRITPROG_M_LOWER,Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
139,WRITPROG_M_UPPER,Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
140,WRITPROG_H,Writing progress measure for pupils with high prior attainment [not populated in 2025]
141,WRITPROG_H_LOWER,Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
142,WRITPROG_H_UPPER,Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
143,WRITPROG_FSM6CLA1A,Writing progress measure for disadvantaged pupils [not populated in 2025]
144,WRITPROG_FSM6CLA1A_LOWER,Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
145,WRITPROG_FSM6CLA1A_UPPER,Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
146,WRITPROG_NotFSM6CLA1A,Writing progress measure for non-disadvantaged pupils [not populated in 2025]
147,WRITPROG_NotFSM6CLA1A_LOWER,Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
148,WRITPROG_NotFSM6CLA1A_UPPER,Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
149,DIFFN_WRITPROG,Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
150,WRITPROG_EAL,Writing progress measure for EAL pupils [not populated in 2025]
151,WRITPROG_EAL_LOWER,Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
152,WRITPROG_EAL_UPPER,Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
153,WRITPROG_MOBN,Writing progress measure for non-mobile pupils [not populated in 2025]
154,WRITPROG_MOBN_LOWER,Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
155,WRITPROG_MOBN_UPPER,Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
156,MATPROG_B,Maths progress measure for boys [not populated in 2025]
157,MATPROG_B_LOWER,Maths progress measure for boys - lower confidence limit [not populated in 2025]
158,MATPROG_B_UPPER,Maths progress measure for boys - upper confidence limit [not populated in 2025]
159,MATPROG_G,Maths progress measure for girls [not populated in 2025]
160,MATPROG_G_LOWER,Maths progress measure for girls - lower confidence limit [not populated in 2025]
161,MATPROG_G_UPPER,Maths progress measure for girls - upper confidence limit [not populated in 2025]
162,MATPROG_L,Maths progress measure for pupils with low prior attainment [not populated in 2025]
163,MATPROG_L_LOWER,Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
164,MATPROG_L_UPPER,Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
165,MATPROG_M,Maths progress measure for pupils with medium prior attainment [not populated in 2025]
166,MATPROG_M_LOWER,Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
167,MATPROG_M_UPPER,Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
168,MATPROG_H,Maths progress measure for pupils with high prior attainment [not populated in 2025]
169,MATPROG_H_LOWER,Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
170,MATPROG_H_UPPER,Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
171,MATPROG_FSM6CLA1A,Maths progress measure for disadvantaged pupils [not populated in 2025]
172,MATPROG_FSM6CLA1A_LOWER,Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
173,MATPROG_FSM6CLA1A_UPPER,Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
174,MATPROG_NotFSM6CLA1A,Maths progress measure for non-disadvantaged pupils [not populated in 2025]
175,MATPROG_NotFSM6CLA1A_LOWER,Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
176,MATPROG_NotFSM6CLA1A_UPPER,Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
177,DIFFN_MATPROG,Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
178,MATPROG_EAL,Maths progress measure for EAL pupils [not populated in 2025]
179,MATPROG_EAL_LOWER,Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
180,MATPROG_EAL_UPPER,Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
181,MATPROG_MOBN,Maths progress measure for non-mobile pupils [not populated in 2025]
182,MATPROG_MOBN_LOWER,Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
183,MATPROG_MOBN_UPPER,Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
184,READ_AVERAGE_B,Average scaled score in reading for boys
185,READ_AVERAGE_G,Average scaled score in reading for girls
186,READ_AVERAGE_L,Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
187,READ_AVERAGE_M,Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
188,READ_AVERAGE_H,Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
189,READ_AVERAGE_FSM6CLA1A,Average scaled score in reading for disadvantaged pupils
190,READ_AVERAGE_NotFSM6CLA1A,Average scaled score in reading for non-disadvantaged pupils
191,READ_AVERAGE_EAL,Average scaled score in reading for EAL pupils
192,READ_AVERAGE_MOBN,Average scaled score in reading for MOBN pupils
193,MAT_AVERAGE_B,Average scaled score in maths for boys
194,MAT_AVERAGE_G,Average scaled score in maths for girls
195,MAT_AVERAGE_L,Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
196,MAT_AVERAGE_M,Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
197,MAT_AVERAGE_H,Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
198,MAT_AVERAGE_FSM6CLA1A,Average scaled score in maths for disadvantaged pupils
199,MAT_AVERAGE_NotFSM6CLA1A,Average scaled score in maths for non-disadvantaged pupils
200,MAT_AVERAGE_EAL,Average scaled score in maths for EAL pupils
201,MAT_AVERAGE_MOBN,Average scaled score in maths for MOBN pupils
202,GPS_AVERAGE_B,Average scaled score in GPS for boys
203,GPS_AVERAGE_G,Average scaled score in GPS for girls
204,GPS_AVERAGE_L,Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
205,GPS_AVERAGE_M,Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
206,GPS_AVERAGE_H,Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
207,GPS_AVERAGE_FSM6CLA1A,Average scaled score in GPS for disadvantaged pupils
208,GPS_AVERAGE_NotFSM6CLA1A,Average scaled score in GPS for non-disadvantaged pupils
209,GPS_AVERAGE_EAL,Average scaled score in GPS for EAL pupils
210,GPS_AVERAGE_MOBN,Average scaled score in GPS for MOBN pupils
211,PTREAD_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
212,PTREAD_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
213,PTREAD_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
214,PTREAD_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in reading
215,PTREAD_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in reading
216,PTGPS_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
217,PTGPS_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
218,PTGPS_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
219,PTGPS_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
220,PTGPS_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
221,PTMAT_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
222,PTMAT_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
223,PTMAT_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
224,PTMAT_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in maths
225,PTMAT_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in maths
226,PTWRITTA_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
227,PTWRITTA_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
228,PTWRITTA_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
229,PTWRITTA_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in writing
230,PTWRITTA_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in writing
231,PTREAD_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
232,PTREAD_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
233,PTREAD_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
234,PTREAD_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading
235,PTREAD_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading
236,PTGPS_HIGH_L,"Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
237,PTGPS_HIGH_M,"Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
238,PTGPS_HIGH_H,"Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
239,PTGPS_HIGH_FSM6CLA1A,"Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
240,PTGPS_HIGH_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
241,PTMAT_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
242,PTMAT_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
243,PTMAT_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
244,PTMAT_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in maths
245,PTMAT_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in maths
246,PTWRITTA_HIGH_L,Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
247,PTWRITTA_HIGH_M,Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
248,PTWRITTA_HIGH_H,Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
249,PTWRITTA_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils working at greater depth in writing
250,PTWRITTA_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils working at greater depth in writing
251,TEALGRP1,Number of eligible pupils with English as first language
252,PTEALGRP1,Percentage of eligible pupils with English as first language
253,TEALGRP3,Number of eligible pupils with unclassified language
254,PTEALGRP3,Percentage of eligible pupils with unclassified language
255,TSENELE,Number of eligible pupils with EHC plan
256,PSENELE,Percentage of eligible pupils with EHC plan
257,TSENELK,Number of eligible pupils with SEN support
258,PSENELK,Percentage of eligible pupils with SEN support
259,TSENELEK,Number of eligible pupils with SEN (EHC plan or SEN support)
260,PSENELEK,Percentage of eligible pupils with SEN (EHC plan or SEN support)
261,TELIG_24,Number of eligible pupils 2024
262,PTFSM6CLA1A_24,Percentage of key stage 2 disadvantaged pupils one year prior
263,PTNOTFSM6CLA1A_24,Percentage of key stage 2 pupils who are not disadvantaged one year prior
264,PTRWM_EXP_24,"Percentage of pupils reaching the expected standard in reading, writing and maths one year prior"
265,PTRWM_HIGH_24,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
266,PTRWM_EXP_FSM6CLA1A_24,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
267,PTRWM_HIGH_FSM6CLA1A_24,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
268,PTRWM_EXP_NotFSM6CLA1A_24,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
269,PTRWM_HIGH_NotFSM6CLA1A_24,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
270,READPROG_24,Reading progress measure - one year prior [not populated in 2025]
271,READPROG_LOWER_24,Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
272,READPROG_UPPER_24,Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
273,WRITPROG_24,Writing progress measure - one year prior [not populated in 2025]
274,WRITPROG_LOWER_24,Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
275,WRITPROG_UPPER_24,Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
276,MATPROG_24,Maths progress measure - one year prior [not populated in 2025]
277,MATPROG_LOWER_24,Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
278,MATPROG_UPPER_24,Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
279,READ_AVERAGE_24,Average scaled score in reading - one year prior
280,MAT_AVERAGE_24,Average scaled score in maths - one year prior
281,TELIG_23,Number of eligible pupils 2023
282,PTFSM6CLA1A_23,Percentage of key stage 2 disadvantaged pupils - two years prior
283,PTNOTFSM6CLA1A_23,Percentage of key stage 2 pupils who are not disadvantaged - two years prior
284,PTRWM_EXP_23,"Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior"
285,PTRWM_HIGH_23,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
286,PTRWM_EXP_FSM6CLA1A_23,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
287,PTRWM_HIGH_FSM6CLA1A_23,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
288,PTRWM_EXP_NotFSM6CLA1A_23,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
289,PTRWM_HIGH_NotFSM6CLA1A_23,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
290,READPROG_23,Reading progress measure - two years prior
291,READPROG_LOWER_23,Reading progress measure - lower confidence limit - two years prior
292,READPROG_UPPER_23,Reading progress measure - upper confidence limit - two years prior
293,WRITPROG_23,Writing progress measure - two years prior
294,WRITPROG_LOWER_23,Writing progress measure - lower confidence limit - two years prior
295,WRITPROG_UPPER_23,Writing progress measure - upper confidence limit - two years prior
296,MATPROG_23,Maths progress measure - two years prior
297,MATPROG_LOWER_23,Maths progress measure - lower confidence limit - two years prior
298,MATPROG_UPPER_23,Maths progress measure - upper confidence limit - two years prior
299,READ_AVERAGE_23,Average scaled score in reading - two years prior
300,MAT_AVERAGE_23,Average scaled score in maths - two years prior
301,TELIG_3YR,Total number of pupils at the end of Key Stage 2 over the past three years
302,PTRWM_EXP_3YR,"Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total"
303,PTRWM_HIGH_3YR,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
304,READ_AVERAGE_3YR,Average scaled score in reading - 3 year average
305,MAT_AVERAGE_3YR,Average scaled score in maths - 3 year average
306,READPROG_UNADJUSTED,Unadjusted reading progress measure [not populated in 2025]
307,WRITPROG_UNADJUSTED,Unadjusted writing progress measure [not populated in 2025]
308,MATPROG_UNADJUSTED,Unadjusted maths progress measure [not populated in 2025]
309,READPROG_DESCR,Reading progress measure 'description' [not populated in 2025]
310,WRITPROG_DESCR,Writing progress measure 'description' [not populated in 2025]
311,MATPROG_DESCR,Maths progress measure 'description' [not populated in 2025]
1 Column Field Name Label/Description
2 1 RECTYPE Record type
3 2 AlphaIND Alphabetic index
4 3 LEA Local authority number
5 4 ESTAB Establishment number
6 5 URN School unique reference number
7 6 SCHNAME School/Local authority name
8 7 ADDRESS1 School address (1)
9 8 ADDRESS2 School address (2)
10 9 ADDRESS3 School address (3)
11 10 TOWN School town
12 11 PCODE School postcode
13 12 TELNUM School telephone number
14 13 PCON_CODE School parliamentary constituency code
15 14 PCON_NAME School parliamentary constituency name
16 15 URN_AC Converter academy: URN
17 16 SCHNAME_AC Converter academy: name
18 17 OPEN_AC Converter academy: open date
19 18 NFTYPE School type
20 19 ICLOSE Closed Flag
21 20 RELDENOM Religious denomination
22 21 AGERANGE Age range
23 22 TAB15 School published in secondary school (key stage 4) performance tables
24 23 TAB1618 School published in school and college (key stage 5) performance tables
25 24 TOTPUPS Total number of pupils (including part-time pupils)
26 25 TPUPYEAR Number of pupils aged 11
27 26 TELIG Published eligible pupil number
28 27 BELIG Eligible boys on school roll at time of tests
29 28 GELIG Eligible girls on school roll at time of tests
30 29 PBELIG Percentage of eligible boys on school roll at time of tests
31 30 PGELIG Percentage of eligible girls on school roll at time of tests
32 31 TKS1AVERAGE Cohort level key stage 1 average points score [not populated in 2025]
33 32 TKS1GROUP_L Number of pupils in cohort with low KS1 attainment [not populated in 2025]
34 33 PTKS1GROUP_L Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
35 34 TKS1GROUP_M Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
36 35 PTKS1GROUP_M Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
37 36 TKS1GROUP_H Number of pupils in cohort high KS1 attainment [not populated in 2025]
38 37 PTKS1GROUP_H Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
39 38 TKS1GROUP_NA No. of pupils in KS1 group not calculable [not populated in 2025]
40 39 PTKS1GROUP_NA Percentage of pupils in KS1group not calculable [not populated in 2025]
41 40 TFSM6CLA1A Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
42 41 PTFSM6CLA1A Percentage of key stage 2 disadvantaged pupils
43 42 TNotFSM6CLA1A Number of key stage 2 pupils who are not disadvantaged
44 43 PTNotFSM6CLA1A Percentage of key stage 2 pupils who are not disadvantaged
45 44 TEALGRP2 Number of eligible pupils with English as additional language (EAL)
46 45 PTEALGRP2 Percentage of eligible pupils with English as additional language (EAL)
47 46 TMOBN Number of eligible pupils classified as non-mobile
48 47 PTMOBN Percentage of eligible pupils classified as non-mobile
49 48 PTRWM_EXP Percentage of pupils reaching the expected standard in reading, writing and maths
50 49 PTRWM_HIGH Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
51 50 READPROG Reading progress measure [not populated in 2025]
52 51 READPROG_LOWER Reading progress measure - lower confidence limit [not populated in 2025]
53 52 READPROG_UPPER Reading progress measure - upper confidence limit [not populated in 2025]
54 53 READCOV Reading progress measure - coverage [not populated in 2025]
55 54 WRITPROG Writing progress measure [not populated in 2025]
56 55 WRITPROG_LOWER Writing progress measure - lower confidence limit [not populated in 2025]
57 56 WRITPROG_UPPER Writing progress measure - upper confidence limit [not populated in 2025]
58 57 WRITCOV Writing progress measure - coverage [not populated in 2025]
59 58 MATPROG Maths progress measure [not populated in 2025]
60 59 MATPROG_LOWER Maths progress measure - lower confidence limit [not populated in 2025]
61 60 MATPROG_UPPER Maths progress measure - upper confidence limit [not populated in 2025]
62 61 MATCOV Maths progress measure - coverage [not populated in 2025]
63 62 PTREAD_EXP Percentage of pupils reaching the expected standard in reading
64 63 PTREAD_HIGH Percentage of pupils achieving a high score in reading
65 64 PTREAD_AT Percentage of pupils absent from or not able to access the test in reading
66 65 READ_AVERAGE Average scaled score in reading
67 66 PTGPS_EXP Percentage of pupils reaching the expected standard in grammar, punctuation and spelling
68 67 PTGPS_HIGH Percentage of pupils achieving a high score in grammar, punctuation and spelling
69 68 PTGPS_AT Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling
70 69 GPS_AVERAGE Average scaled score in grammar, punctuation and spelling
71 70 PTMAT_EXP Percentage of pupils reaching the expected standard in maths
72 71 PTMAT_HIGH Percentage of pupils achieving a high score in maths
73 72 PTMAT_AT Percentage of pupils absent from or not able to access the test in maths
74 73 MAT_AVERAGE Average scaled score in maths
75 74 PTWRITTA_EXP Percentage of pupils reaching the expected standard in writing
76 75 PTWRITTA_HIGH Percentage of pupils working at greater depth within the expected standard in writing
77 76 PTWRITTA_WTS Percentage of pupils working towards the expected standard in writing
78 77 PTWRITTA_AD Percentage of pupils absent or disapplied in writing TA
79 78 PTSCITA_EXP Percentage of pupils reaching the expected standard in science TA
80 79 PTSCITA_AD Percentage of pupils absent or disapplied in science TA
81 80 PTRWM_EXP_B Percentage of boys reaching the expected standard in reading, writing and maths
82 81 PTRWM_EXP_G Percentage of girls reaching the expected standard in reading, writing and maths
83 82 PTRWM_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
84 83 PTRWM_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
85 84 PTRWM_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
86 85 PTRWM_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths
87 86 PTRWM_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths
88 87 DIFFN_RWM_EXP Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths
89 88 PTRWM_EXP_EAL Percentage of EAL pupils reaching the expected standard in reading, writing and maths
90 89 PTRWM_EXP_MOBN Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths
91 90 PTRWM_HIGH_B Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
92 91 PTRWM_HIGH_G Percentage of girls reaching the HIGHected standard in reading, writing and maths
93 92 PTRWM_HIGH_L Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
94 93 PTRWM_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
95 94 PTRWM_HIGH_H Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
96 95 PTRWM_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
97 96 PTRWM_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
98 97 DIFFN_RWM_HIGH Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths
99 98 PTRWM_HIGH_EAL Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
100 99 PTRWM_HIGH_MOBN Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
101 100 READPROG_B Reading progress measure for boys [not populated in 2025]
102 101 READPROG_B_LOWER Reading progress measure for boys - lower confidence limit [not populated in 2025]
103 102 READPROG_B_UPPER Reading progress measure for boys - upper confidence limit [not populated in 2025]
104 103 READPROG_G Reading progress measure for girls [not populated in 2025]
105 104 READPROG_G_LOWER Reading progress measure for girls - lower confidence limit [not populated in 2025]
106 105 READPROG_G_UPPER Reading progress measure for girls - upper confidence limit [not populated in 2025]
107 106 READPROG_L Reading progress measure for pupils with low prior attainment [not populated in 2025]
108 107 READPROG_L_LOWER Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
109 108 READPROG_L_UPPER Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
110 109 READPROG_M Reading progress measure for pupils with medium prior attainment [not populated in 2025]
111 110 READPROG_M_LOWER Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
112 111 READPROG_M_UPPER Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
113 112 READPROG_H Reading progress measure for pupils with high prior attainment [not populated in 2025]
114 113 READPROG_H_LOWER Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
115 114 READPROG_H_UPPER Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
116 115 READPROG_FSM6CLA1A Reading progress measure for disadvantaged pupils [not populated in 2025]
117 116 READPROG_FSM6CLA1A_LOWER Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
118 117 READPROG_FSM6CLA1A_UPPER Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
119 118 READPROG_NotFSM6CLA1A Reading progress measure for non-disadvantaged pupils [not populated in 2025]
120 119 READPROG_NotFSM6CLA1A_LOWER Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
121 120 READPROG_NotFSM6CLA1A_UPPER Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
122 121 DIFFN_READPROG Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
123 122 READPROG_EAL Reading progress measure for EAL pupils [not populated in 2025]
124 123 READPROG_EAL_LOWER Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
125 124 READPROG_EAL_UPPER Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
126 125 READPROG_MOBN Reading progress measure for non-mobile pupils [not populated in 2025]
127 126 READPROG_MOBN_LOWER Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
128 127 READPROG_MOBN_UPPER Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
129 128 WRITPROG_B Writing progress measure for boys [not populated in 2025]
130 129 WRITPROG_B_LOWER Writing progress measure for boys - lower confidence limit [not populated in 2025]
131 130 WRITPROG_B_UPPER Writing progress measure for boys - upper confidence limit [not populated in 2025]
132 131 WRITPROG_G Writing progress measure for girls [not populated in 2025]
133 132 WRITPROG_G_LOWER Writing progress measure for girls - lower confidence limit [not populated in 2025]
134 133 WRITPROG_G_UPPER Writing progress measure for girls - upper confidence limit [not populated in 2025]
135 134 WRITPROG_L Writing progress measure for pupils with low prior attainment [not populated in 2025]
136 135 WRITPROG_L_LOWER Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
137 136 WRITPROG_L_UPPER Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
138 137 WRITPROG_M Writing progress measure for pupils with medium prior attainment [not populated in 2025]
139 138 WRITPROG_M_LOWER Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
140 139 WRITPROG_M_UPPER Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
141 140 WRITPROG_H Writing progress measure for pupils with high prior attainment [not populated in 2025]
142 141 WRITPROG_H_LOWER Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
143 142 WRITPROG_H_UPPER Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
144 143 WRITPROG_FSM6CLA1A Writing progress measure for disadvantaged pupils [not populated in 2025]
145 144 WRITPROG_FSM6CLA1A_LOWER Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
146 145 WRITPROG_FSM6CLA1A_UPPER Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
147 146 WRITPROG_NotFSM6CLA1A Writing progress measure for non-disadvantaged pupils [not populated in 2025]
148 147 WRITPROG_NotFSM6CLA1A_LOWER Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
149 148 WRITPROG_NotFSM6CLA1A_UPPER Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
150 149 DIFFN_WRITPROG Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
151 150 WRITPROG_EAL Writing progress measure for EAL pupils [not populated in 2025]
152 151 WRITPROG_EAL_LOWER Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
153 152 WRITPROG_EAL_UPPER Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
154 153 WRITPROG_MOBN Writing progress measure for non-mobile pupils [not populated in 2025]
155 154 WRITPROG_MOBN_LOWER Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
156 155 WRITPROG_MOBN_UPPER Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
157 156 MATPROG_B Maths progress measure for boys [not populated in 2025]
158 157 MATPROG_B_LOWER Maths progress measure for boys - lower confidence limit [not populated in 2025]
159 158 MATPROG_B_UPPER Maths progress measure for boys - upper confidence limit [not populated in 2025]
160 159 MATPROG_G Maths progress measure for girls [not populated in 2025]
161 160 MATPROG_G_LOWER Maths progress measure for girls - lower confidence limit [not populated in 2025]
162 161 MATPROG_G_UPPER Maths progress measure for girls - upper confidence limit [not populated in 2025]
163 162 MATPROG_L Maths progress measure for pupils with low prior attainment [not populated in 2025]
164 163 MATPROG_L_LOWER Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
165 164 MATPROG_L_UPPER Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
166 165 MATPROG_M Maths progress measure for pupils with medium prior attainment [not populated in 2025]
167 166 MATPROG_M_LOWER Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
168 167 MATPROG_M_UPPER Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
169 168 MATPROG_H Maths progress measure for pupils with high prior attainment [not populated in 2025]
170 169 MATPROG_H_LOWER Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
171 170 MATPROG_H_UPPER Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
172 171 MATPROG_FSM6CLA1A Maths progress measure for disadvantaged pupils [not populated in 2025]
173 172 MATPROG_FSM6CLA1A_LOWER Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
174 173 MATPROG_FSM6CLA1A_UPPER Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
175 174 MATPROG_NotFSM6CLA1A Maths progress measure for non-disadvantaged pupils [not populated in 2025]
176 175 MATPROG_NotFSM6CLA1A_LOWER Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
177 176 MATPROG_NotFSM6CLA1A_UPPER Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
178 177 DIFFN_MATPROG Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
179 178 MATPROG_EAL Maths progress measure for EAL pupils [not populated in 2025]
180 179 MATPROG_EAL_LOWER Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
181 180 MATPROG_EAL_UPPER Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
182 181 MATPROG_MOBN Maths progress measure for non-mobile pupils [not populated in 2025]
183 182 MATPROG_MOBN_LOWER Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
184 183 MATPROG_MOBN_UPPER Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
185 184 READ_AVERAGE_B Average scaled score in reading for boys
186 185 READ_AVERAGE_G Average scaled score in reading for girls
187 186 READ_AVERAGE_L Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
188 187 READ_AVERAGE_M Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
189 188 READ_AVERAGE_H Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
190 189 READ_AVERAGE_FSM6CLA1A Average scaled score in reading for disadvantaged pupils
191 190 READ_AVERAGE_NotFSM6CLA1A Average scaled score in reading for non-disadvantaged pupils
192 191 READ_AVERAGE_EAL Average scaled score in reading for EAL pupils
193 192 READ_AVERAGE_MOBN Average scaled score in reading for MOBN pupils
194 193 MAT_AVERAGE_B Average scaled score in maths for boys
195 194 MAT_AVERAGE_G Average scaled score in maths for girls
196 195 MAT_AVERAGE_L Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
197 196 MAT_AVERAGE_M Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
198 197 MAT_AVERAGE_H Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
199 198 MAT_AVERAGE_FSM6CLA1A Average scaled score in maths for disadvantaged pupils
200 199 MAT_AVERAGE_NotFSM6CLA1A Average scaled score in maths for non-disadvantaged pupils
201 200 MAT_AVERAGE_EAL Average scaled score in maths for EAL pupils
202 201 MAT_AVERAGE_MOBN Average scaled score in maths for MOBN pupils
203 202 GPS_AVERAGE_B Average scaled score in GPS for boys
204 203 GPS_AVERAGE_G Average scaled score in GPS for girls
205 204 GPS_AVERAGE_L Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
206 205 GPS_AVERAGE_M Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
207 206 GPS_AVERAGE_H Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
208 207 GPS_AVERAGE_FSM6CLA1A Average scaled score in GPS for disadvantaged pupils
209 208 GPS_AVERAGE_NotFSM6CLA1A Average scaled score in GPS for non-disadvantaged pupils
210 209 GPS_AVERAGE_EAL Average scaled score in GPS for EAL pupils
211 210 GPS_AVERAGE_MOBN Average scaled score in GPS for MOBN pupils
212 211 PTREAD_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
213 212 PTREAD_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
214 213 PTREAD_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
215 214 PTREAD_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in reading
216 215 PTREAD_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in reading
217 216 PTGPS_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
218 217 PTGPS_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
219 218 PTGPS_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
220 219 PTGPS_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling
221 220 PTGPS_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling
222 221 PTMAT_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
223 222 PTMAT_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
224 223 PTMAT_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
225 224 PTMAT_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in maths
226 225 PTMAT_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in maths
227 226 PTWRITTA_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
228 227 PTWRITTA_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
229 228 PTWRITTA_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
230 229 PTWRITTA_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in writing
231 230 PTWRITTA_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in writing
232 231 PTREAD_HIGH_L Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
233 232 PTREAD_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
234 233 PTREAD_HIGH_H Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
235 234 PTREAD_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in reading
236 235 PTREAD_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in reading
237 236 PTGPS_HIGH_L Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
238 237 PTGPS_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
239 238 PTGPS_HIGH_H Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
240 239 PTGPS_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling
241 240 PTGPS_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling
242 241 PTMAT_HIGH_L Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
243 242 PTMAT_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
244 243 PTMAT_HIGH_H Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
245 244 PTMAT_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in maths
246 245 PTMAT_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in maths
247 246 PTWRITTA_HIGH_L Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
248 247 PTWRITTA_HIGH_M Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
249 248 PTWRITTA_HIGH_H Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
250 249 PTWRITTA_HIGH_FSM6CLA1A Percentage of disadvantaged pupils working at greater depth in writing
251 250 PTWRITTA_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils working at greater depth in writing
252 251 TEALGRP1 Number of eligible pupils with English as first language
253 252 PTEALGRP1 Percentage of eligible pupils with English as first language
254 253 TEALGRP3 Number of eligible pupils with unclassified language
255 254 PTEALGRP3 Percentage of eligible pupils with unclassified language
256 255 TSENELE Number of eligible pupils with EHC plan
257 256 PSENELE Percentage of eligible pupils with EHC plan
258 257 TSENELK Number of eligible pupils with SEN support
259 258 PSENELK Percentage of eligible pupils with SEN support
260 259 TSENELEK Number of eligible pupils with SEN (EHC plan or SEN support)
261 260 PSENELEK Percentage of eligible pupils with SEN (EHC plan or SEN support)
262 261 TELIG_24 Number of eligible pupils 2024
263 262 PTFSM6CLA1A_24 Percentage of key stage 2 disadvantaged pupils one year prior
264 263 PTNOTFSM6CLA1A_24 Percentage of key stage 2 pupils who are not disadvantaged one year prior
265 264 PTRWM_EXP_24 Percentage of pupils reaching the expected standard in reading, writing and maths one year prior
266 265 PTRWM_HIGH_24 Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
267 266 PTRWM_EXP_FSM6CLA1A_24 Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior
268 267 PTRWM_HIGH_FSM6CLA1A_24 Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
269 268 PTRWM_EXP_NotFSM6CLA1A_24 Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior
270 269 PTRWM_HIGH_NotFSM6CLA1A_24 Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
271 270 READPROG_24 Reading progress measure - one year prior [not populated in 2025]
272 271 READPROG_LOWER_24 Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
273 272 READPROG_UPPER_24 Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
274 273 WRITPROG_24 Writing progress measure - one year prior [not populated in 2025]
275 274 WRITPROG_LOWER_24 Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
276 275 WRITPROG_UPPER_24 Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
277 276 MATPROG_24 Maths progress measure - one year prior [not populated in 2025]
278 277 MATPROG_LOWER_24 Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
279 278 MATPROG_UPPER_24 Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
280 279 READ_AVERAGE_24 Average scaled score in reading - one year prior
281 280 MAT_AVERAGE_24 Average scaled score in maths - one year prior
282 281 TELIG_23 Number of eligible pupils 2023
283 282 PTFSM6CLA1A_23 Percentage of key stage 2 disadvantaged pupils - two years prior
284 283 PTNOTFSM6CLA1A_23 Percentage of key stage 2 pupils who are not disadvantaged - two years prior
285 284 PTRWM_EXP_23 Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior
286 285 PTRWM_HIGH_23 Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
287 286 PTRWM_EXP_FSM6CLA1A_23 Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior
288 287 PTRWM_HIGH_FSM6CLA1A_23 Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
289 288 PTRWM_EXP_NotFSM6CLA1A_23 Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior
290 289 PTRWM_HIGH_NotFSM6CLA1A_23 Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
291 290 READPROG_23 Reading progress measure - two years prior
292 291 READPROG_LOWER_23 Reading progress measure - lower confidence limit - two years prior
293 292 READPROG_UPPER_23 Reading progress measure - upper confidence limit - two years prior
294 293 WRITPROG_23 Writing progress measure - two years prior
295 294 WRITPROG_LOWER_23 Writing progress measure - lower confidence limit - two years prior
296 295 WRITPROG_UPPER_23 Writing progress measure - upper confidence limit - two years prior
297 296 MATPROG_23 Maths progress measure - two years prior
298 297 MATPROG_LOWER_23 Maths progress measure - lower confidence limit - two years prior
299 298 MATPROG_UPPER_23 Maths progress measure - upper confidence limit - two years prior
300 299 READ_AVERAGE_23 Average scaled score in reading - two years prior
301 300 MAT_AVERAGE_23 Average scaled score in maths - two years prior
302 301 TELIG_3YR Total number of pupils at the end of Key Stage 2 over the past three years
303 302 PTRWM_EXP_3YR Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total
304 303 PTRWM_HIGH_3YR Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
305 304 READ_AVERAGE_3YR Average scaled score in reading - 3 year average
306 305 MAT_AVERAGE_3YR Average scaled score in maths - 3 year average
307 306 READPROG_UNADJUSTED Unadjusted reading progress measure [not populated in 2025]
308 307 WRITPROG_UNADJUSTED Unadjusted writing progress measure [not populated in 2025]
309 308 MATPROG_UNADJUSTED Unadjusted maths progress measure [not populated in 2025]
310 309 READPROG_DESCR Reading progress measure 'description' [not populated in 2025]
311 310 WRITPROG_DESCR Writing progress measure 'description' [not populated in 2025]
312 311 MATPROG_DESCR Maths progress measure 'description' [not populated in 2025]

View File

@@ -1,154 +0,0 @@
LEA,LA Name,REGION,REGION NAME
841,Darlington,1,North East A
840,County Durham,1,North East A
805,Hartlepool,1,North East A
806,Middlesbrough,1,North East A
807,Redcar and Cleveland,1,North East A
808,Stockton-on-Tees,1,North East A
390,Gateshead,3,North East B
391,Newcastle upon Tyne,3,North East B
392,North Tyneside,3,North East B
929,Northumberland,3,North East B
393,South Tyneside,3,North East B
394,Sunderland,3,North East B
889,Blackburn with Darwen,6,North West A
890,Blackpool,6,North West A
942,Cumberland,6,North West A
943,Westmorland and Furness ,6,North West A
888,Lancashire,6,North West A
350,Bolton,7,North West B
351,Bury,7,North West B
352,Manchester,7,North West B
353,Oldham,7,North West B
354,Rochdale,7,North West B
355,Salford,7,North West B
356,Stockport,7,North West B
357,Tameside,7,North West B
358,Trafford,7,North West B
359,Wigan,7,North West B
895,Cheshire East,9,North West C
896,Cheshire West and Chester,9,North West C
876,Halton,9,North West C
340,Knowsley,9,North West C
341,Liverpool,9,North West C
343,Sefton,9,North West C
342,St. Helens,9,North West C
877,Warrington,9,North West C
344,Wirral,9,North West C
811,East Riding of Yorkshire,10,North Yorkshire and The Humber
810,"Kingston Upon Hull, City of",10,North Yorkshire and The Humber
812,North East Lincolnshire,10,North Yorkshire and The Humber
813,North Lincolnshire,10,North Yorkshire and The Humber
815,North Yorkshire,10,North Yorkshire and The Humber
816,York,10,North Yorkshire and The Humber
370,Barnsley,12,South and West Yorkshire
380,Bradford,12,South and West Yorkshire
381,Calderdale,12,South and West Yorkshire
371,Doncaster,12,South and West Yorkshire
382,Kirklees,12,South and West Yorkshire
383,Leeds,12,South and West Yorkshire
372,Rotherham,12,South and West Yorkshire
373,Sheffield,12,South and West Yorkshire
384,Wakefield,12,South and West Yorkshire
831,Derby,14,East Midlands A
830,Derbyshire,14,East Midlands A
892,Nottingham,14,East Midlands A
891,Nottinghamshire,14,East Midlands A
856,Leicester,16,East Midlands B
855,Leicestershire,16,East Midlands B
925,Lincolnshire,16,East Midlands B
940,North Northamptonshire,16,East Midlands B
941,West Northamptonshire,16,East Midlands B
857,Rutland,16,East Midlands B
893,Shropshire,20,West Midlands A
860,Staffordshire,20,West Midlands A
861,Stoke-on-Trent,20,West Midlands A
894,Telford and Wrekin,20,West Midlands A
884,"Herefordshire, County of",22,West Midlands B
885,Worcestershire,22,West Midlands B
330,Birmingham,24,West Midlands C
331,Coventry,24,West Midlands C
332,Dudley,24,West Midlands C
333,Sandwell,24,West Midlands C
334,Solihull,24,West Midlands C
335,Walsall,24,West Midlands C
937,Warwickshire,24,West Midlands C
336,Wolverhampton,24,West Midlands C
822,Bedford,25,East of England A
873,Cambridgeshire,25,East of England A
823,Central Bedfordshire,25,East of England A
919,Hertfordshire,25,East of England A
821,Luton,25,East of England A
874,Peterborough,25,East of England A
881,Essex,27,East of England B
926,Norfolk,27,East of England B
882,Southend-on-Sea,27,East of England B
935,Suffolk,27,East of England B
883,Thurrock,27,East of England B
202,Camden,31,London Central
206,Islington,31,London Central
207,Kensington and Chelsea,31,London Central
208,Lambeth,31,London Central
210,Southwark,31,London Central
212,Wandsworth,31,London Central
213,Westminster,31,London Central
301,Barking and Dagenham,32,London East
303,Bexley,32,London East
201,City of London,32,London East
203,Greenwich,32,London East
204,Hackney,32,London East
311,Havering,32,London East
209,Lewisham,32,London East
316,Newham,32,London East
317,Redbridge,32,London East
211,Tower Hamlets,32,London East
302,Barnet,33,London North
308,Enfield,33,London North
309,Haringey,33,London North
320,Waltham Forest,33,London North
305,Bromley,34,London South
306,Croydon,34,London South
314,Kingston upon Thames,34,London South
315,Merton,34,London South
318,Richmond upon Thames,34,London South
319,Sutton,34,London South
304,Brent,35,London West
307,Ealing,35,London West
205,Hammersmith and Fulham,35,London West
310,Harrow,35,London West
312,Hillingdon,35,London West
313,Hounslow,35,London West
867,Bracknell Forest,36,South East A
825,Buckinghamshire,36,South East A
826,Milton Keynes,36,South East A
931,Oxfordshire,36,South East A
870,Reading,36,South East A
871,Slough,36,South East A
869,West Berkshire,36,South East A
868,Windsor and Maidenhead,36,South East A
872,Wokingham,36,South East A
850,Hampshire,37,South East B
921,Isle of Wight,37,South East B
851,Portsmouth,37,South East B
852,Southampton,37,South East B
936,Surrey,38,South East C
938,West Sussex,38,South East C
846,Brighton and Hove,39,South East D
845,East Sussex,39,South East D
886,Kent,39,South East D
887,Medway,39,South East D
839,"Bournemouth, Christchurch and Poole",43,South West A
908,Cornwall,43,South West A
878,Devon,43,South West A
838,Dorset,43,South West A
420,Isles of Scilly,43,South West A
879,Plymouth,43,South West A
933,Somerset,43,South West A
880,Torbay,43,South West A
800,Bath and North East Somerset,45,South West B
801,"Bristol, City of",45,South West B
916,Gloucestershire,45,South West B
802,North Somerset,45,South West B
803,South Gloucestershire,45,South West B
866,Swindon,45,South West B
865,Wiltshire,45,South West B
1 LEA LA Name REGION REGION NAME
2 841 Darlington 1 North East A
3 840 County Durham 1 North East A
4 805 Hartlepool 1 North East A
5 806 Middlesbrough 1 North East A
6 807 Redcar and Cleveland 1 North East A
7 808 Stockton-on-Tees 1 North East A
8 390 Gateshead 3 North East B
9 391 Newcastle upon Tyne 3 North East B
10 392 North Tyneside 3 North East B
11 929 Northumberland 3 North East B
12 393 South Tyneside 3 North East B
13 394 Sunderland 3 North East B
14 889 Blackburn with Darwen 6 North West A
15 890 Blackpool 6 North West A
16 942 Cumberland 6 North West A
17 943 Westmorland and Furness 6 North West A
18 888 Lancashire 6 North West A
19 350 Bolton 7 North West B
20 351 Bury 7 North West B
21 352 Manchester 7 North West B
22 353 Oldham 7 North West B
23 354 Rochdale 7 North West B
24 355 Salford 7 North West B
25 356 Stockport 7 North West B
26 357 Tameside 7 North West B
27 358 Trafford 7 North West B
28 359 Wigan 7 North West B
29 895 Cheshire East 9 North West C
30 896 Cheshire West and Chester 9 North West C
31 876 Halton 9 North West C
32 340 Knowsley 9 North West C
33 341 Liverpool 9 North West C
34 343 Sefton 9 North West C
35 342 St. Helens 9 North West C
36 877 Warrington 9 North West C
37 344 Wirral 9 North West C
38 811 East Riding of Yorkshire 10 North Yorkshire and The Humber
39 810 Kingston Upon Hull, City of 10 North Yorkshire and The Humber
40 812 North East Lincolnshire 10 North Yorkshire and The Humber
41 813 North Lincolnshire 10 North Yorkshire and The Humber
42 815 North Yorkshire 10 North Yorkshire and The Humber
43 816 York 10 North Yorkshire and The Humber
44 370 Barnsley 12 South and West Yorkshire
45 380 Bradford 12 South and West Yorkshire
46 381 Calderdale 12 South and West Yorkshire
47 371 Doncaster 12 South and West Yorkshire
48 382 Kirklees 12 South and West Yorkshire
49 383 Leeds 12 South and West Yorkshire
50 372 Rotherham 12 South and West Yorkshire
51 373 Sheffield 12 South and West Yorkshire
52 384 Wakefield 12 South and West Yorkshire
53 831 Derby 14 East Midlands A
54 830 Derbyshire 14 East Midlands A
55 892 Nottingham 14 East Midlands A
56 891 Nottinghamshire 14 East Midlands A
57 856 Leicester 16 East Midlands B
58 855 Leicestershire 16 East Midlands B
59 925 Lincolnshire 16 East Midlands B
60 940 North Northamptonshire 16 East Midlands B
61 941 West Northamptonshire 16 East Midlands B
62 857 Rutland 16 East Midlands B
63 893 Shropshire 20 West Midlands A
64 860 Staffordshire 20 West Midlands A
65 861 Stoke-on-Trent 20 West Midlands A
66 894 Telford and Wrekin 20 West Midlands A
67 884 Herefordshire, County of 22 West Midlands B
68 885 Worcestershire 22 West Midlands B
69 330 Birmingham 24 West Midlands C
70 331 Coventry 24 West Midlands C
71 332 Dudley 24 West Midlands C
72 333 Sandwell 24 West Midlands C
73 334 Solihull 24 West Midlands C
74 335 Walsall 24 West Midlands C
75 937 Warwickshire 24 West Midlands C
76 336 Wolverhampton 24 West Midlands C
77 822 Bedford 25 East of England A
78 873 Cambridgeshire 25 East of England A
79 823 Central Bedfordshire 25 East of England A
80 919 Hertfordshire 25 East of England A
81 821 Luton 25 East of England A
82 874 Peterborough 25 East of England A
83 881 Essex 27 East of England B
84 926 Norfolk 27 East of England B
85 882 Southend-on-Sea 27 East of England B
86 935 Suffolk 27 East of England B
87 883 Thurrock 27 East of England B
88 202 Camden 31 London Central
89 206 Islington 31 London Central
90 207 Kensington and Chelsea 31 London Central
91 208 Lambeth 31 London Central
92 210 Southwark 31 London Central
93 212 Wandsworth 31 London Central
94 213 Westminster 31 London Central
95 301 Barking and Dagenham 32 London East
96 303 Bexley 32 London East
97 201 City of London 32 London East
98 203 Greenwich 32 London East
99 204 Hackney 32 London East
100 311 Havering 32 London East
101 209 Lewisham 32 London East
102 316 Newham 32 London East
103 317 Redbridge 32 London East
104 211 Tower Hamlets 32 London East
105 302 Barnet 33 London North
106 308 Enfield 33 London North
107 309 Haringey 33 London North
108 320 Waltham Forest 33 London North
109 305 Bromley 34 London South
110 306 Croydon 34 London South
111 314 Kingston upon Thames 34 London South
112 315 Merton 34 London South
113 318 Richmond upon Thames 34 London South
114 319 Sutton 34 London South
115 304 Brent 35 London West
116 307 Ealing 35 London West
117 205 Hammersmith and Fulham 35 London West
118 310 Harrow 35 London West
119 312 Hillingdon 35 London West
120 313 Hounslow 35 London West
121 867 Bracknell Forest 36 South East A
122 825 Buckinghamshire 36 South East A
123 826 Milton Keynes 36 South East A
124 931 Oxfordshire 36 South East A
125 870 Reading 36 South East A
126 871 Slough 36 South East A
127 869 West Berkshire 36 South East A
128 868 Windsor and Maidenhead 36 South East A
129 872 Wokingham 36 South East A
130 850 Hampshire 37 South East B
131 921 Isle of Wight 37 South East B
132 851 Portsmouth 37 South East B
133 852 Southampton 37 South East B
134 936 Surrey 38 South East C
135 938 West Sussex 38 South East C
136 846 Brighton and Hove 39 South East D
137 845 East Sussex 39 South East D
138 886 Kent 39 South East D
139 887 Medway 39 South East D
140 839 Bournemouth, Christchurch and Poole 43 South West A
141 908 Cornwall 43 South West A
142 878 Devon 43 South West A
143 838 Dorset 43 South West A
144 420 Isles of Scilly 43 South West A
145 879 Plymouth 43 South West A
146 933 Somerset 43 South West A
147 880 Torbay 43 South West A
148 800 Bath and North East Somerset 45 South West B
149 801 Bristol, City of 45 South West B
150 916 Gloucestershire 45 South West B
151 802 North Somerset 45 South West B
152 803 South Gloucestershire 45 South West B
153 866 Swindon 45 South West B
154 865 Wiltshire 45 South West B

View File

@@ -8,8 +8,6 @@
# 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)
# KESTRA_USER — Kestra UI username (optional)
# KESTRA_PASSWORD — Kestra UI password (optional)
services:
@@ -103,87 +101,6 @@ services:
retries: 5
start_period: 10s
# ── Kestra — workflow orchestrator (legacy, kept during migration) ────
kestra:
image: kestra/kestra:latest
container_name: schoolcompare_kestra
command: server standalone
ports:
- "8090:8080"
volumes:
- kestra_storage:/app/storage
environment:
KESTRA_CONFIGURATION: |
datasources:
postgres:
url: jdbc:postgresql://sc_database:5432/kestra
driverClassName: org.postgresql.Driver
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
kestra:
repository:
type: postgres
queue:
type: postgres
storage:
type: local
local:
base-path: /app/storage
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8081/health | grep -q '\"status\":\"UP\"'"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# ── Kestra init (legacy, kept during migration) ──────────────────────
kestra-init:
image: privaterepo.sitaru.org/tudor/school_compare-kestra-init:latest
container_name: schoolcompare_kestra_init
environment:
KESTRA_URL: http://kestra:8080
KESTRA_USER: ${KESTRA_USER:-}
KESTRA_PASSWORD: ${KESTRA_PASSWORD:-}
depends_on:
kestra:
condition: service_healthy
networks:
- backend
restart: "no"
# ── Data integrator (legacy, kept during migration) ──────────────────
integrator:
image: privaterepo.sitaru.org/tudor/school_compare-integrator:latest
container_name: schoolcompare_integrator
ports:
- "8001:8001"
environment:
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
DATA_DIR: /data
BACKEND_URL: http://backend:80
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
PYTHONUNBUFFERED: 1
volumes:
- supplementary_data:/data
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# ── Airflow API Server + UI ───────────────────────────────────────────
airflow-api-server:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
@@ -282,7 +199,5 @@ networks:
volumes:
postgres_data:
kestra_storage:
supplementary_data:
typesense_data:
airflow_logs:

View File

@@ -9,6 +9,7 @@ services:
POSTGRES_USER: schoolcompare
POSTGRES_PASSWORD: schoolcompare
POSTGRES_DB: schoolcompare
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
@@ -119,6 +120,8 @@ services:
PG_DATABASE: schoolcompare
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
BACKEND_URL: http://backend:80
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
volumes:
depends_on:

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1a1612"/>
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="20" cy="20" r="3" fill="#e07256"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

View File

@@ -1,663 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SchoolCompare | Compare Primary School Performance</title>
<!-- Primary Meta Tags -->
<meta
name="description"
content="Compare primary school KS2 performance across England. Search, filter and compare Reading, Writing and Maths results for thousands of schools."
/>
<meta
name="keywords"
content="school comparison, KS2 results, primary school performance, England schools, SATs results"
/>
<meta name="author" content="SchoolCompare" />
<meta name="robots" content="index, follow" />
<!-- Analytics -->
<script
defer
src="https://analytics.schoolcompare.co.uk/script.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
></script>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Canonical -->
<link rel="canonical" href="https://schoolcompare.co.uk/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://schoolcompare.co.uk/" />
<meta
property="og:title"
content="SchoolCompare | Compare Primary School Performance"
/>
<meta
property="og:description"
content="Compare primary school KS2 performance across England. Search and compare Reading, Writing and Maths results."
/>
<meta property="og:site_name" content="SchoolCompare" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:url" content="https://schoolcompare.co.uk/" />
<meta
name="twitter:title"
content="SchoolCompare | Compare Primary School Performance"
/>
<meta
name="twitter:description"
content="Compare primary school KS2 performance across England."
/>
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "SchoolCompare",
"url": "https://schoolcompare.co.uk",
"description": "Compare primary school KS2 performance across England",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "GBP"
},
"author": {
"@type": "Organization",
"name": "SchoolCompare",
"url": "https://schoolcompare.co.uk"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Leaflet Map Library -->
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="noise-overlay"></div>
<header class="header">
<div class="header-content">
<a href="/" class="logo">
<div class="logo-icon">
<svg
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="20"
cy="20"
r="18"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<circle cx="20" cy="20" r="4" fill="currentColor" />
</svg>
</div>
<div class="logo-text">
<span class="logo-title">SchoolCompare</span>
<span class="logo-subtitle">schoolcompare.co.uk</span>
</div>
</a>
<nav class="nav">
<a href="/" class="nav-link active" data-view="home"
>Home</a
>
<a href="/compare" class="nav-link" data-view="compare"
>Compare</a
>
<a href="/rankings" class="nav-link" data-view="rankings"
>Rankings</a
>
</nav>
</div>
</header>
<main class="main">
<!-- Home View -->
<section id="home-view" class="view active">
<div class="hero">
<h1 class="hero-title">
Compare Primary School Performance
</h1>
<p class="hero-subtitle">
Search and compare KS2 results across England's primary
schools
</p>
</div>
<div class="search-section">
<div class="search-mode-toggle">
<button class="search-mode-btn active" data-mode="name">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
Find by Name
</button>
<button class="search-mode-btn" data-mode="location">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
/>
<circle cx="12" cy="10" r="3" />
</svg>
Find by Location
</button>
</div>
<div id="name-search-panel" class="search-panel active">
<div class="search-container">
<input
type="text"
id="school-search"
class="search-input"
placeholder="Search primary schools by name..."
/>
<div class="search-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
</div>
<div class="filter-row">
<select
id="local-authority-filter"
class="filter-select"
>
<option value="">All Areas</option>
</select>
<select id="type-filter" class="filter-select">
<option value="">All School Types</option>
</select>
</div>
</div>
<div id="location-search-panel" class="search-panel">
<div class="location-input-group">
<input
type="text"
id="postcode-search"
class="search-input postcode-input"
placeholder="Enter postcode..."
/>
<select
id="radius-select"
class="filter-select radius-select"
>
<option value="0.5" selected>1/2 mile</option>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
</select>
<select
id="type-filter-location"
class="filter-select"
>
<option value="">All School Types</option>
</select>
<button
id="location-search-btn"
class="btn btn-primary location-btn"
>
Find Nearby
</button>
</div>
</div>
</div>
<div class="view-toggle" id="view-toggle" style="display: none">
<button class="view-toggle-btn active" data-view="list">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
List
</button>
<button class="view-toggle-btn" data-view="map">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
/>
<circle cx="12" cy="10" r="3" />
</svg>
Map
</button>
</div>
<div class="results-container" id="results-container">
<div class="results-map" id="results-map"></div>
<div class="schools-grid" id="schools-grid">
<!-- School cards populated by JS -->
</div>
</div>
</section>
<!-- Compare View -->
<section id="compare-view" class="view">
<div class="compare-header">
<h2 class="section-title">Compare Primary Schools</h2>
<p class="section-subtitle">
Select schools to compare their KS2 performance over
time
</p>
</div>
<div class="compare-search-section">
<input
type="text"
id="compare-search"
class="search-input"
placeholder="Add a school to compare..."
/>
<div id="compare-results" class="compare-results"></div>
</div>
<div class="selected-schools" id="selected-schools">
<div class="empty-selection">
<div class="empty-icon">
<svg
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect
x="6"
y="10"
width="36"
height="28"
rx="2"
/>
<path d="M6 18h36" />
<circle
cx="14"
cy="14"
r="2"
fill="currentColor"
/>
<circle
cx="22"
cy="14"
r="2"
fill="currentColor"
/>
</svg>
</div>
<p>Search and add schools to compare</p>
</div>
</div>
<div
class="charts-section"
id="charts-section"
style="display: none"
>
<div class="metric-selector">
<label>Select KS2 Metric:</label>
<select id="metric-select" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">
Reading, Writing & Maths Combined %
</option>
<option value="reading_expected_pct">
Reading Expected %
</option>
<option value="writing_expected_pct">
Writing Expected %
</option>
<option value="maths_expected_pct">
Maths Expected %
</option>
<option value="gps_expected_pct">
GPS Expected %
</option>
<option value="science_expected_pct">
Science Expected %
</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">
RWM Combined Higher %
</option>
<option value="reading_high_pct">
Reading Higher %
</option>
<option value="writing_high_pct">
Writing Higher %
</option>
<option value="maths_high_pct">
Maths Higher %
</option>
<option value="gps_high_pct">
GPS Higher %
</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">
Reading Progress
</option>
<option value="writing_progress">
Writing Progress
</option>
<option value="maths_progress">
Maths Progress
</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">
Reading Avg Score
</option>
<option value="maths_avg_score">
Maths Avg Score
</option>
<option value="gps_avg_score">
GPS Avg Score
</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">
RWM Expected % (Boys)
</option>
<option value="rwm_expected_girls_pct">
RWM Expected % (Girls)
</option>
<option value="rwm_high_boys_pct">
RWM Higher % (Boys)
</option>
<option value="rwm_high_girls_pct">
RWM Higher % (Girls)
</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">
RWM Expected % (Disadvantaged)
</option>
<option
value="rwm_expected_non_disadvantaged_pct"
>
RWM Expected % (Non-Disadvantaged)
</option>
<option value="disadvantaged_gap">
Disadvantaged Gap vs National
</option>
</optgroup>
<optgroup label="School Context">
<option value="disadvantaged_pct">
% Disadvantaged Pupils
</option>
<option value="eal_pct">% EAL Pupils</option>
<option value="sen_support_pct">
% SEN Support
</option>
<option value="stability_pct">
% Pupil Stability
</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">
RWM Expected % (3-Year Avg)
</option>
<option value="reading_avg_3yr">
Reading Score (3-Year Avg)
</option>
<option value="maths_avg_3yr">
Maths Score (3-Year Avg)
</option>
</optgroup>
</select>
</div>
<div class="chart-container">
<canvas id="comparison-chart"></canvas>
</div>
<div class="data-table-container">
<table class="data-table" id="comparison-table">
<thead>
<tr id="table-header"></tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</div>
</section>
<!-- Rankings View -->
<section id="rankings-view" class="view">
<div class="rankings-header">
<h2 class="section-title">Primary School Rankings</h2>
<p class="section-subtitle">
Top performing primary schools ranked by KS2 metric
</p>
</div>
<div class="rankings-controls">
<select id="ranking-area" class="filter-select">
<option value="">All Areas</option>
<!-- Populated by JS -->
</select>
<select id="ranking-metric" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">
Reading, Writing & Maths Combined %
</option>
<option value="reading_expected_pct">
Reading Expected %
</option>
<option value="writing_expected_pct">
Writing Expected %
</option>
<option value="maths_expected_pct">
Maths Expected %
</option>
<option value="gps_expected_pct">
GPS Expected %
</option>
<option value="science_expected_pct">
Science Expected %
</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">
RWM Combined Higher %
</option>
<option value="reading_high_pct">
Reading Higher %
</option>
<option value="writing_high_pct">
Writing Higher %
</option>
<option value="maths_high_pct">
Maths Higher %
</option>
<option value="gps_high_pct">GPS Higher %</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">
Reading Progress
</option>
<option value="writing_progress">
Writing Progress
</option>
<option value="maths_progress">
Maths Progress
</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">
Reading Avg Score
</option>
<option value="maths_avg_score">
Maths Avg Score
</option>
<option value="gps_avg_score">GPS Avg Score</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">
RWM Expected % (Boys)
</option>
<option value="rwm_expected_girls_pct">
RWM Expected % (Girls)
</option>
<option value="rwm_high_boys_pct">
RWM Higher % (Boys)
</option>
<option value="rwm_high_girls_pct">
RWM Higher % (Girls)
</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">
RWM Expected % (Disadvantaged)
</option>
<option value="rwm_expected_non_disadvantaged_pct">
RWM Expected % (Non-Disadvantaged)
</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">
RWM Expected % (3-Year Avg)
</option>
</optgroup>
</select>
<select id="ranking-year" class="filter-select">
<!-- Populated by JS -->
</select>
</div>
<div class="rankings-list" id="rankings-list">
<!-- Rankings populated by JS -->
</div>
</section>
</main>
<!-- School Detail Modal -->
<div class="modal" id="school-modal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<button class="modal-close" id="modal-close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div class="modal-header">
<button
class="btn btn-primary modal-compare-btn"
id="add-to-compare"
>
Add to Compare
</button>
<h2 id="modal-school-name"></h2>
<div class="modal-meta" id="modal-meta"></div>
<div class="modal-details" id="modal-details"></div>
</div>
<div class="modal-body">
<div class="modal-chart-container">
<canvas id="school-detail-chart"></canvas>
</div>
<div class="modal-stats" id="modal-stats"></div>
<div class="modal-map-container" id="modal-map-container">
<h4>Location</h4>
<div class="modal-map" id="modal-map"></div>
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-content">
<div class="footer-contact">
<a href="mailto:contact@schoolcompare.co.uk">Contact Us</a>
</div>
<div class="footer-source">
<p>
Data source:
<a
href="https://www.compare-school-performance.service.gov.uk/"
target="_blank"
>UK Government - Compare School Performance</a
>
</p>
</div>
</div>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -1,8 +0,0 @@
User-agent: *
Allow: /
Allow: /compare
Allow: /rankings
Disallow: /api/
Sitemap: https://schoolcompare.co.uk/sitemap.xml

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://schoolcompare.co.uk/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://schoolcompare.co.uk/compare</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://schoolcompare.co.uk/rankings</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY scripts/ ./scripts/
COPY server.py .
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8001"]

View File

@@ -1,6 +0,0 @@
FROM alpine:3.19
RUN apk add --no-cache curl
COPY flows/ /flows/
COPY docker/kestra-init.sh /kestra-init.sh
RUN chmod +x /kestra-init.sh
CMD ["/kestra-init.sh"]

View File

@@ -1,59 +0,0 @@
#!/bin/sh
set -e
KESTRA_URL="${KESTRA_URL:-http://kestra:8080}"
MAX_WAIT=120
# Basic auth — set KESTRA_USER / KESTRA_PASSWORD if authentication is enabled
AUTH=""
if [ -n "$KESTRA_USER" ] && [ -n "$KESTRA_PASSWORD" ]; then
AUTH="-u ${KESTRA_USER}:${KESTRA_PASSWORD}"
fi
echo "Waiting for Kestra API at ${KESTRA_URL}..."
elapsed=0
until curl -sf $AUTH "${KESTRA_URL}/api/v1/flows/search" > /dev/null 2>&1; do
if [ "$elapsed" -ge "$MAX_WAIT" ]; then
echo "ERROR: Kestra API not reachable after ${MAX_WAIT}s"
exit 1
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "Kestra API is ready."
echo "Importing flows..."
for f in /flows/*.yml; do
name="$(basename "$f")"
echo " -> $name"
http_code=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
-X POST "${KESTRA_URL}/api/v1/flows" \
-H "Content-Type: application/x-yaml" \
--data-binary "@${f}")
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
echo " created"
elif [ "$http_code" = "409" ]; then
ns=$(grep '^namespace:' "$f" | awk '{print $2}')
id=$(grep '^id:' "$f" | awk '{print $2}')
http_code2=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
-X PUT "${KESTRA_URL}/api/v1/flows/${ns}/${id}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@${f}")
if [ "$http_code2" = "200" ] || [ "$http_code2" = "201" ]; then
echo " updated"
else
echo " ERROR updating $name: HTTP $http_code2"
cat /tmp/kestra_resp; echo
exit 1
fi
else
echo " ERROR importing $name: HTTP $http_code"
cat /tmp/kestra_resp; echo
exit 1
fi
done
echo "All flows imported."

View File

@@ -1,26 +0,0 @@
id: admissions-annual-update
namespace: schoolcompare.data
description: Download and load school admissions data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 7 *" # 1 July annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/admissions?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/admissions?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M

View File

@@ -1,26 +0,0 @@
id: census-annual-update
namespace: schoolcompare.data
description: Download and load School Census (SPC) data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 9 *" # 1 September annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/census?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/census?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M

View File

@@ -1,26 +0,0 @@
id: finance-annual-update
namespace: schoolcompare.data
description: Fetch FBIT financial benchmarking data from DfE API for all schools
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 12 *" # 1 December annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/finance?action=download
method: POST
timeout: PT120M # Fetches per-school from API — ~20k schools
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/finance?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 2
interval: PT30M

View File

@@ -1,31 +0,0 @@
id: gias-weekly-update
namespace: schoolcompare.data
description: Download and load GIAS (Get Information About Schools) bulk CSV
triggers:
- id: weekly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 3 * * 0" # Every Sunday at 03:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/gias?action=download
method: POST
timeout: PT30M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/gias?action=load
method: POST
timeout: PT30M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "GIAS update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M

View File

@@ -1,26 +0,0 @@
id: idaci-annual-check
namespace: schoolcompare.data
description: Download IoD2019 IDACI file and compute deprivation scores for all schools
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 5 1 1 *" # 1 January annually at 05:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/idaci?action=download
method: POST
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/idaci?action=load
method: POST
timeout: PT60M
retry:
type: constant
maxAttempts: 2
interval: PT30M

View File

@@ -1,23 +0,0 @@
id: ks2-reimport
namespace: schoolcompare.data
description: Re-import KS2 attainment data from bundled CSV files (use after DB wipe)
# No scheduled trigger — run manually from the Kestra UI when needed.
tasks:
- id: reimport
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ks2?action=load
method: POST
allowFailed: false
timeout: PT30S # fire-and-forget; backend runs migration in background
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "KS2 re-import FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 2
interval: PT5M

View File

@@ -1,33 +0,0 @@
id: ofsted-monthly-update
namespace: schoolcompare.data
description: Download and load Ofsted Monthly Management Information CSV
triggers:
- id: monthly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 2 1 * *" # 1st of each month at 02:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ofsted?action=download
method: POST
allowFailed: false
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ofsted?action=load
method: POST
allowFailed: false
timeout: PT30M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "Ofsted update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M

View File

@@ -1,31 +0,0 @@
id: parent-view-monthly-check
namespace: schoolcompare.data
description: Download and load Ofsted Parent View open data (released ~3x/year)
triggers:
- id: monthly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 3 1 * *" # 1st of each month at 03:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/parent_view?action=download
method: POST
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/parent_view?action=load
method: POST
timeout: PT20M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "Parent View update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M

View File

@@ -1,26 +0,0 @@
id: phonics-annual-update
namespace: schoolcompare.data
description: Download and load Phonics Screening Check data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 5 1 9 *" # 1 September annually at 05:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/phonics?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/phonics?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M

View File

@@ -1,26 +0,0 @@
id: sen-detail-annual-update
namespace: schoolcompare.data
description: Download and load SEN primary need breakdown via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 15 9 *" # 15 September annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/sen_detail?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/sen_detail?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M

View File

@@ -1,7 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
requests==2.32.3
pandas==2.2.3
openpyxl==3.1.5
psycopg2-binary==2.9.9
sqlalchemy==2.0.35

View File

@@ -1,14 +0,0 @@
"""Configuration for the data integrator."""
import os
from pathlib import Path
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql://schoolcompare:schoolcompare@db:5432/schoolcompare",
)
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
SUPPLEMENTARY_DIR = DATA_DIR / "supplementary"
BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:80")
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "changeme")

View File

@@ -1,23 +0,0 @@
"""Database connection for the integrator."""
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from config import DATABASE_URL
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@contextmanager
def get_session():
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

View File

@@ -1,184 +0,0 @@
"""
School Admissions data downloader and loader.
Source: EES publication "primary-and-secondary-school-applications-and-offers"
Content API release ZIP → supporting-files/AppsandOffers_*_SchoolLevel*.csv
Update: Annual (June/July post-offer round)
"""
import argparse
import re
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import download_release_zip_csv
DEST_DIR = SUPPLEMENTARY_DIR / "admissions"
PUBLICATION_SLUG = "primary-and-secondary-school-applications-and-offers"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", "Z", ""}
# Maps actual CSV column names → internal field names
COLUMN_MAP = {
# School identifier
"school_urn": "urn",
# Year — e.g. 202526 → 2025
"time_period": "time_period_raw",
# PAN (places offered)
"total_number_places_offered": "pan",
# Applications (total times put as any preference)
"times_put_as_any_preferred_school": "total_applications",
# 1st-preference applications
"times_put_as_1st_preference": "times_1st_pref",
# 1st-preference offers
"number_1st_preference_offers": "offers_1st_pref",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
dest_file = dest / "admissions_school_level_latest.csv"
return download_release_zip_csv(
PUBLICATION_SLUG,
dest_file,
zip_member_keyword="schoollevel",
)
def _parse_int(val) -> int | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace(",", "")
if s in NULL_VALUES:
return None
try:
return int(float(s))
except ValueError:
return None
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No admissions CSV found in {dest}")
path = files[-1]
print(f" Admissions: loading {path} ...")
df = pd.read_csv(path, encoding="utf-8-sig", low_memory=False)
# Rename columns we care about
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
# Filter to primary schools only
if "school_phase" in df.columns:
df = df[df["school_phase"].str.lower() == "primary"]
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Derive year from time_period (e.g. 202526 → 2025)
def _extract_year(val) -> int | None:
s = str(val).strip()
m = re.match(r"(\d{4})\d{2}", s)
if m:
return int(m.group(1))
m2 = re.search(r"20(\d{2})", s)
if m2:
return int("20" + m2.group(1))
return None
if "time_period_raw" in df.columns:
df["year"] = df["time_period_raw"].apply(_extract_year)
else:
year_m = re.search(r"20(\d{2})", path.stem)
df["year"] = int("20" + year_m.group(1)) if year_m else None
df = df.dropna(subset=["year"])
df["year"] = df["year"].astype(int)
# Keep most recent year per school (file may contain multiple years)
df = df.sort_values("year", ascending=False).groupby("urn").first().reset_index()
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
year = int(row["year"])
pan = _parse_int(row.get("pan"))
total_apps = _parse_int(row.get("total_applications"))
times_1st = _parse_int(row.get("times_1st_pref"))
offers_1st = _parse_int(row.get("offers_1st_pref"))
# % of 1st-preference applicants who received an offer
if times_1st and times_1st > 0 and offers_1st is not None:
pct_1st = round(offers_1st / times_1st * 100, 1)
else:
pct_1st = None
oversubscribed = (
True if (pan and times_1st and times_1st > pan) else
False if (pan and times_1st and times_1st <= pan) else
None
)
session.execute(
text("""
INSERT INTO school_admissions
(urn, year, published_admission_number, total_applications,
first_preference_offers_pct, oversubscribed)
VALUES (:urn, :year, :pan, :total_apps, :pct_1st, :oversubscribed)
ON CONFLICT (urn, year) DO UPDATE SET
published_admission_number = EXCLUDED.published_admission_number,
total_applications = EXCLUDED.total_applications,
first_preference_offers_pct = EXCLUDED.first_preference_offers_pct,
oversubscribed = EXCLUDED.oversubscribed
"""),
{
"urn": urn, "year": year, "pan": pan,
"total_apps": total_apps, "pct_1st": pct_1st,
"oversubscribed": oversubscribed,
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Processed {inserted} records...")
print(f" Admissions: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,148 +0,0 @@
"""
School Census (SPC) downloader and loader.
Source: EES publication "schools-pupils-and-their-characteristics"
Update: Annual (June)
Adds: class_size_avg, ethnicity breakdown by school
"""
import argparse
import re
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import get_latest_csv_url, download_csv
DEST_DIR = SUPPLEMENTARY_DIR / "census"
PUBLICATION_SLUG = "schools-pupils-and-their-characteristics"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
COLUMN_MAP = {
"URN": "urn",
"urn": "urn",
"YEAR": "year",
"Year": "year",
# Class size
"average_class_size": "class_size_avg",
"AVCLAS": "class_size_avg",
"avg_class_size": "class_size_avg",
# Ethnicity — DfE uses ethnicity major group percentages
"perc_white": "ethnicity_white_pct",
"perc_asian": "ethnicity_asian_pct",
"perc_black": "ethnicity_black_pct",
"perc_mixed": "ethnicity_mixed_pct",
"perc_other_ethnic": "ethnicity_other_pct",
"PTWHITE": "ethnicity_white_pct",
"PTASIAN": "ethnicity_asian_pct",
"PTBLACK": "ethnicity_black_pct",
"PTMIXED": "ethnicity_mixed_pct",
"PTOTHER": "ethnicity_other_pct",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
if not url:
raise RuntimeError(f"Could not find CSV URL for census publication")
filename = url.split("/")[-1].split("?")[0] or "census_latest.csv"
return download_csv(url, dest / filename)
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No census CSV found in {dest}")
path = files[-1]
print(f" Census: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
year = None
m = re.search(r"20(\d{2})", path.stem)
if m:
year = int("20" + m.group(1))
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
if not row_year:
continue
session.execute(
text("""
INSERT INTO school_census
(urn, year, class_size_avg,
ethnicity_white_pct, ethnicity_asian_pct, ethnicity_black_pct,
ethnicity_mixed_pct, ethnicity_other_pct)
VALUES (:urn, :year, :class_size_avg,
:white, :asian, :black, :mixed, :other)
ON CONFLICT (urn, year) DO UPDATE SET
class_size_avg = EXCLUDED.class_size_avg,
ethnicity_white_pct = EXCLUDED.ethnicity_white_pct,
ethnicity_asian_pct = EXCLUDED.ethnicity_asian_pct,
ethnicity_black_pct = EXCLUDED.ethnicity_black_pct,
ethnicity_mixed_pct = EXCLUDED.ethnicity_mixed_pct,
ethnicity_other_pct = EXCLUDED.ethnicity_other_pct
"""),
{
"urn": urn,
"year": row_year,
"class_size_avg": _parse_pct(row.get("class_size_avg")),
"white": _parse_pct(row.get("ethnicity_white_pct")),
"asian": _parse_pct(row.get("ethnicity_asian_pct")),
"black": _parse_pct(row.get("ethnicity_black_pct")),
"mixed": _parse_pct(row.get("ethnicity_mixed_pct")),
"other": _parse_pct(row.get("ethnicity_other_pct")),
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Census: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,111 +0,0 @@
"""
Shared EES (Explore Education Statistics) API client.
Two APIs are available:
- Statistics API: https://api.education.gov.uk/statistics/v1 (only ~13 publications)
- Content API: https://content.explore-education-statistics.service.gov.uk/api
Covers all publications; use this for admissions and other data not in the stats API.
Download all files for a release as a ZIP from /api/releases/{id}/files.
"""
import io
import zipfile
from pathlib import Path
from typing import Optional
import requests
STATS_API_BASE = "https://api.education.gov.uk/statistics/v1"
CONTENT_API_BASE = "https://content.explore-education-statistics.service.gov.uk/api"
TIMEOUT = 60
def get_publication_files(publication_slug: str) -> list[dict]:
"""Return list of data-set file descriptors for a publication (statistics API)."""
url = f"{STATS_API_BASE}/publications/{publication_slug}/data-set-files"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json().get("results", [])
def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str]:
"""
Find the most recent CSV download URL for a publication (statistics API).
Optionally filter by a keyword in the file name.
"""
files = get_publication_files(publication_slug)
for entry in files:
name = entry.get("name", "").lower()
if keyword and keyword.lower() not in name:
continue
csv_url = entry.get("csvDownloadUrl") or entry.get("file", {}).get("url")
if csv_url:
return csv_url
return None
def get_content_release_id(publication_slug: str) -> str:
"""Return the latest release ID for a publication via the content API."""
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases/latest"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json()["id"]
def download_release_zip_csv(
publication_slug: str,
dest_path: Path,
zip_member_keyword: str = "",
) -> Path:
"""
Download the full-release ZIP from the EES content API and extract one CSV.
If zip_member_keyword is given, the first member whose path contains that
keyword (case-insensitive) is extracted; otherwise the first .csv found is used.
Returns dest_path (the extracted CSV file).
"""
if dest_path.exists():
print(f" EES: {dest_path.name} already exists, skipping.")
return dest_path
release_id = get_content_release_id(publication_slug)
zip_url = f"{CONTENT_API_BASE}/releases/{release_id}/files"
print(f" EES: downloading release ZIP for '{publication_slug}' ...")
resp = requests.get(zip_url, timeout=300, stream=True)
resp.raise_for_status()
data = b"".join(resp.iter_content(chunk_size=65536))
with zipfile.ZipFile(io.BytesIO(data)) as z:
members = z.namelist()
target = None
kw = zip_member_keyword.lower()
for m in members:
if m.endswith(".csv") and (not kw or kw in m.lower()):
target = m
break
if not target:
raise ValueError(
f"No CSV matching '{zip_member_keyword}' in ZIP. Members: {members}"
)
print(f" EES: extracting '{target}' ...")
dest_path.parent.mkdir(parents=True, exist_ok=True)
with z.open(target) as src, open(dest_path, "wb") as dst:
dst.write(src.read())
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
return dest_path
def download_csv(url: str, dest_path: Path) -> Path:
"""Download a CSV from EES to dest_path."""
if dest_path.exists():
print(f" EES: {dest_path.name} already exists, skipping.")
return dest_path
print(f" EES: downloading {url} ...")
resp = requests.get(url, timeout=300, stream=True)
resp.raise_for_status()
dest_path.parent.mkdir(parents=True, exist_ok=True)
with open(dest_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
return dest_path

View File

@@ -1,143 +0,0 @@
"""
FBIT (Financial Benchmarking and Insights Tool) financial data loader.
Source: https://schools-financial-benchmarking.service.gov.uk/api/
Update: Annual (December — data for the prior financial year)
"""
import argparse
import sys
import time
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "finance"
API_BASE = "https://schools-financial-benchmarking.service.gov.uk/api"
RATE_LIMIT_DELAY = 0.1 # seconds between requests
def download(data_dir: Path | None = None) -> Path:
"""
Fetch per-URN financial data from FBIT API and save as CSV.
Batches all school URNs from the database.
"""
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
# Determine year from API (use current year minus 1 for completed financials)
from datetime import date
year = date.today().year - 1
dest_file = dest / f"fbit_{year}.csv"
if dest_file.exists():
print(f" Finance: {dest_file.name} already exists, skipping download.")
return dest_file
# Get all URNs from the database
with get_session() as session:
from sqlalchemy import text
rows = session.execute(text("SELECT urn FROM schools")).fetchall()
urns = [r[0] for r in rows]
print(f" Finance: fetching FBIT data for {len(urns)} schools (year {year}) ...")
records = []
errors = 0
for i, urn in enumerate(urns):
if i % 500 == 0:
print(f" {i}/{len(urns)} ...")
try:
resp = requests.get(
f"{API_BASE}/schoolFinancialDataObject/{urn}",
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
if data:
records.append({
"urn": urn,
"year": year,
"per_pupil_spend": data.get("totalExpenditure") and
data.get("numberOfPupils") and
round(data["totalExpenditure"] / data["numberOfPupils"], 2),
"staff_cost_pct": data.get("staffCostPercent"),
"teacher_cost_pct": data.get("teachingStaffCostPercent"),
"support_staff_cost_pct": data.get("educationSupportStaffCostPercent"),
"premises_cost_pct": data.get("premisesStaffCostPercent"),
})
elif resp.status_code not in (404, 400):
errors += 1
except Exception:
errors += 1
time.sleep(RATE_LIMIT_DELAY)
df = pd.DataFrame(records)
df.to_csv(dest_file, index=False)
print(f" Finance: saved {len(records)} records to {dest_file} ({errors} errors)")
return dest_file
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
files = sorted(dest.glob("fbit_*.csv"))
if not files:
raise FileNotFoundError(f"No finance CSV found in {dest}")
path = files[-1]
print(f" Finance: loading {path} ...")
df = pd.read_csv(path)
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
session.execute(
text("""
INSERT INTO school_finance
(urn, year, per_pupil_spend, staff_cost_pct, teacher_cost_pct,
support_staff_cost_pct, premises_cost_pct)
VALUES (:urn, :year, :per_pupil, :staff, :teacher, :support, :premises)
ON CONFLICT (urn, year) DO UPDATE SET
per_pupil_spend = EXCLUDED.per_pupil_spend,
staff_cost_pct = EXCLUDED.staff_cost_pct,
teacher_cost_pct = EXCLUDED.teacher_cost_pct,
support_staff_cost_pct = EXCLUDED.support_staff_cost_pct,
premises_cost_pct = EXCLUDED.premises_cost_pct
"""),
{
"urn": int(row["urn"]),
"year": int(row["year"]),
"per_pupil": float(row["per_pupil_spend"]) if pd.notna(row.get("per_pupil_spend")) else None,
"staff": float(row["staff_cost_pct"]) if pd.notna(row.get("staff_cost_pct")) else None,
"teacher": float(row["teacher_cost_pct"]) if pd.notna(row.get("teacher_cost_pct")) else None,
"support": float(row["support_staff_cost_pct"]) if pd.notna(row.get("support_staff_cost_pct")) else None,
"premises": float(row["premises_cost_pct"]) if pd.notna(row.get("premises_cost_pct")) else None,
},
)
inserted += 1
if inserted % 2000 == 0:
session.flush()
print(f" Finance: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,159 +0,0 @@
"""
GIAS (Get Information About Schools) bulk CSV downloader and loader.
Source: https://get-information-schools.service.gov.uk/Downloads
Update: Daily; we refresh weekly.
Adds: website, headteacher_name, capacity, trust_name, trust_uid, gender, nursery_provision
"""
import argparse
import sys
from datetime import date
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "gias"
# GIAS bulk download URL — date is injected at runtime
GIAS_URL_TEMPLATE = "https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/public/edubasealldata{date}.csv"
COLUMN_MAP = {
"URN": "urn",
"SchoolWebsite": "website",
"SchoolCapacity": "capacity",
"TrustName": "trust_name",
"TrustUID": "trust_uid",
"Gender (name)": "gender",
"NurseryProvision (name)": "nursery_provision_raw",
"HeadTitle": "head_title",
"HeadFirstName": "head_first",
"HeadLastName": "head_last",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
today = date.today().strftime("%Y%m%d")
url = GIAS_URL_TEMPLATE.format(date=today)
filename = f"gias_{today}.csv"
dest_file = dest / filename
if dest_file.exists():
print(f" GIAS: {filename} already exists, skipping download.")
return dest_file
print(f" GIAS: downloading {url} ...")
resp = requests.get(url, timeout=300, stream=True)
# GIAS may not have today's file yet — fall back to yesterday
if resp.status_code == 404:
from datetime import timedelta
yesterday = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
url = GIAS_URL_TEMPLATE.format(date=yesterday)
filename = f"gias_{yesterday}.csv"
dest_file = dest / filename
if dest_file.exists():
print(f" GIAS: {filename} already exists, skipping download.")
return dest_file
resp = requests.get(url, timeout=300, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" GIAS: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
return dest_file
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
files = sorted(dest.glob("gias_*.csv"))
if not files:
raise FileNotFoundError(f"No GIAS CSV found in {dest}")
path = files[-1]
print(f" GIAS: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Build headteacher_name from parts
def build_name(row):
parts = [
str(row.get("head_title", "") or "").strip(),
str(row.get("head_first", "") or "").strip(),
str(row.get("head_last", "") or "").strip(),
]
return " ".join(p for p in parts if p) or None
df["headteacher_name"] = df.apply(build_name, axis=1)
df["nursery_provision"] = df.get("nursery_provision_raw", pd.Series()).apply(
lambda v: True if str(v).strip().lower().startswith("has") else False if pd.notna(v) else None
)
def clean_str(val):
s = str(val).strip() if pd.notna(val) else None
return s if s and s.lower() not in ("nan", "none", "") else None
updated = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
session.execute(
text("""
UPDATE schools SET
website = :website,
headteacher_name = :headteacher_name,
capacity = :capacity,
trust_name = :trust_name,
trust_uid = :trust_uid,
gender = :gender,
nursery_provision = :nursery_provision
WHERE urn = :urn
"""),
{
"urn": urn,
"website": clean_str(row.get("website")),
"headteacher_name": row.get("headteacher_name"),
"capacity": int(row["capacity"]) if pd.notna(row.get("capacity")) and str(row.get("capacity")).strip().isdigit() else None,
"trust_name": clean_str(row.get("trust_name")),
"trust_uid": clean_str(row.get("trust_uid")),
"gender": clean_str(row.get("gender")),
"nursery_provision": row.get("nursery_provision"),
},
)
updated += 1
if updated % 5000 == 0:
session.flush()
print(f" Updated {updated} schools...")
print(f" GIAS: updated {updated} school records")
return {"inserted": 0, "updated": updated, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
path = download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,176 +0,0 @@
"""
IDACI (Income Deprivation Affecting Children Index) loader.
Source: English Indices of Deprivation 2019
https://www.gov.uk/government/statistics/english-indices-of-deprivation-2019
This is a one-time download (5-yearly release). We join school postcodes to LSOAs
via postcodes.io, then look up IDACI scores from the IoD2019 file.
Update: ~5-yearly (next release expected 2025/26)
"""
import argparse
import sys
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "idaci"
# IoD 2019 supplementary data — "Income Deprivation Affecting Children Index (IDACI)"
IOD_2019_URL = (
"https://assets.publishing.service.gov.uk/government/uploads/system/uploads/"
"attachment_data/file/833970/File_1_-_IMD2019_Index_of_Multiple_Deprivation.xlsx"
)
POSTCODES_IO_BATCH = "https://api.postcodes.io/postcodes"
BATCH_SIZE = 100
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
filename = "iod2019_idaci.xlsx"
dest_file = dest / filename
if dest_file.exists():
print(f" IDACI: {filename} already exists, skipping download.")
return dest_file
print(f" IDACI: downloading IoD2019 file ...")
resp = requests.get(IOD_2019_URL, timeout=300, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" IDACI: saved {dest_file}")
return dest_file
def _postcode_to_lsoa(postcodes: list[str]) -> dict[str, str]:
"""Batch-resolve postcodes to LSOA codes via postcodes.io."""
result = {}
valid = [p.strip().upper() for p in postcodes if p and len(str(p).strip()) >= 5]
valid = list(set(valid))
for i in range(0, len(valid), BATCH_SIZE):
batch = valid[i:i + BATCH_SIZE]
try:
resp = requests.post(POSTCODES_IO_BATCH, json={"postcodes": batch}, timeout=30)
if resp.status_code == 200:
for item in resp.json().get("result", []):
if item and item.get("result"):
lsoa = item["result"].get("lsoa")
if lsoa:
result[item["query"].upper()] = lsoa
except Exception as e:
print(f" Warning: postcodes.io batch failed: {e}")
return result
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
if path is None:
files = sorted(dest.glob("*.xlsx"))
if not files:
raise FileNotFoundError(f"No IDACI file found in {dest}")
path = files[-1]
print(f" IDACI: loading IoD2019 from {path} ...")
# IoD2019 File 1 — sheet "IoD2019 IDACI" or similar
try:
iod_df = pd.read_excel(path, sheet_name=None)
# Find sheet with IDACI data
idaci_sheet = None
for name, df in iod_df.items():
if "IDACI" in name.upper() or "IDACI" in str(df.columns.tolist()).upper():
idaci_sheet = name
break
if idaci_sheet is None:
idaci_sheet = list(iod_df.keys())[0]
df_iod = iod_df[idaci_sheet]
except Exception as e:
raise RuntimeError(f"Could not read IoD2019 file: {e}")
# Normalise column names — IoD2019 uses specific headers
col_lsoa = next((c for c in df_iod.columns if "LSOA" in str(c).upper() and "code" in str(c).lower()), None)
col_score = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "score" in str(c).lower()), None)
col_rank = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "rank" in str(c).lower()), None)
if not col_lsoa or not col_score:
print(f" IDACI columns available: {list(df_iod.columns)[:20]}")
raise ValueError("Could not find LSOA code or IDACI score columns")
df_iod = df_iod[[col_lsoa, col_score]].copy()
df_iod.columns = ["lsoa_code", "idaci_score"]
df_iod = df_iod.dropna()
# Compute decile from rank (or from score distribution)
total = len(df_iod)
df_iod = df_iod.sort_values("idaci_score", ascending=False)
df_iod["idaci_decile"] = (pd.qcut(df_iod["idaci_score"], 10, labels=False) + 1).astype(int)
# Decile 1 = most deprived (highest IDACI score)
df_iod["idaci_decile"] = 11 - df_iod["idaci_decile"]
lsoa_lookup = df_iod.set_index("lsoa_code")[["idaci_score", "idaci_decile"]].to_dict("index")
print(f" IDACI: loaded {len(lsoa_lookup)} LSOA records")
# Fetch all school postcodes from the database
with get_session() as session:
from sqlalchemy import text
rows = session.execute(text("SELECT urn, postcode FROM schools WHERE postcode IS NOT NULL")).fetchall()
postcodes = [r[1] for r in rows]
print(f" IDACI: resolving {len(postcodes)} postcodes via postcodes.io ...")
pc_to_lsoa = _postcode_to_lsoa(postcodes)
print(f" IDACI: resolved {len(pc_to_lsoa)} postcodes to LSOAs")
inserted = skipped = 0
with get_session() as session:
from sqlalchemy import text
for urn, postcode in rows:
lsoa = pc_to_lsoa.get(str(postcode).strip().upper())
if not lsoa:
skipped += 1
continue
iod = lsoa_lookup.get(lsoa)
if not iod:
skipped += 1
continue
session.execute(
text("""
INSERT INTO school_deprivation (urn, lsoa_code, idaci_score, idaci_decile)
VALUES (:urn, :lsoa, :score, :decile)
ON CONFLICT (urn) DO UPDATE SET
lsoa_code = EXCLUDED.lsoa_code,
idaci_score = EXCLUDED.idaci_score,
idaci_decile = EXCLUDED.idaci_decile
"""),
{"urn": urn, "lsoa": lsoa, "score": float(iod["idaci_score"]), "decile": int(iod["idaci_decile"])},
)
inserted += 1
if inserted % 2000 == 0:
session.flush()
print(f" IDACI: upserted {inserted}, skipped {skipped}")
return {"inserted": inserted, "updated": 0, "skipped": skipped}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,49 +0,0 @@
"""
KS2 attainment data re-importer.
Triggers a full re-import of the KS2 CSV data by calling the backend's
admin endpoint. The backend owns the migration logic and CSV column mappings;
this module is a thin trigger so the re-import can be orchestrated via Kestra
like all other data sources.
The CSV files must already be present in the data volume under
/data/{year}/england_ks2final.csv
(populated at deploy time from the repo's data/ directory).
"""
import requests
from config import BACKEND_URL, ADMIN_API_KEY
HEADERS = {"X-API-Key": ADMIN_API_KEY}
def download():
"""No download step — CSVs are shipped with the repo."""
print("KS2 CSVs are bundled in the data volume; no download needed.")
return {"skipped": True}
def load():
"""Trigger KS2 re-import on the backend and return immediately.
The migration (including geocoding) runs as a background thread on the
backend and can take up to an hour. Poll GET /api/admin/reimport-ks2/status
to check progress, or simply wait for schools to appear in the UI.
"""
url = f"{BACKEND_URL}/api/admin/reimport-ks2?geocode=true"
print(f"POST {url}")
resp = requests.post(url, headers=HEADERS, timeout=30)
resp.raise_for_status()
result = resp.json()
print(f"Result: {result}")
return result
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
args = parser.parse_args()
if args.action in ("download", "all"):
download()
if args.action in ("load", "all"):
load()

View File

@@ -1,418 +0,0 @@
"""
Ofsted Monthly Management Information CSV downloader and loader.
Source: https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes
Update: Monthly (released ~2 weeks into each month)
"""
import argparse
import re
import sys
from datetime import date, datetime
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
# Current Ofsted MI download URL — update this when Ofsted releases a new file.
# The URL follows a predictable pattern; we attempt to discover it from the GOV.UK page.
GOV_UK_PAGE = "https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes"
# Column name → internal field, listed in priority order per field.
# First matching column wins; later entries are fallbacks for older file formats.
COLUMN_PRIORITY = {
"urn": ["URN", "Urn", "urn"],
"inspection_date": [
"Inspection start date of latest OEIF graded inspection",
"Inspection start date",
"Inspection date",
"InspectionDate",
],
"publication_date": [
"Publication date of latest OEIF graded inspection",
"Publication date",
"PublicationDate",
],
"inspection_type": [
"Inspection type of latest OEIF graded inspection",
"Inspection type",
"InspectionType",
],
"overall_effectiveness": [
"Latest OEIF overall effectiveness",
"Overall effectiveness",
"OverallEffectiveness",
],
"quality_of_education": [
"Latest OEIF quality of education",
"Quality of education",
"QualityOfEducation",
],
"behaviour_attitudes": [
"Latest OEIF behaviour and attitudes",
"Behaviour and attitudes",
"BehaviourAndAttitudes",
],
"personal_development": [
"Latest OEIF personal development",
"Personal development",
"PersonalDevelopment",
],
"leadership_management": [
"Latest OEIF effectiveness of leadership and management",
"Leadership and management",
"LeadershipAndManagement",
],
"early_years_provision": [
"Latest OEIF early years provision (where applicable)",
"Early years provision",
"EarlyYearsProvision",
],
}
GRADE_MAP = {
"Outstanding": 1, "1": 1, 1: 1,
"Good": 2, "2": 2, 2: 2,
"Requires improvement": 3, "3": 3, 3: 3,
"Requires Improvement": 3,
"Inadequate": 4, "4": 4, 4: 4,
}
# Report Card grade text → integer (1=Exceptional … 5=Urgent improvement)
RC_GRADE_MAP = {
"exceptional": 1,
"strong standard": 2,
"strong": 2,
"expected standard": 3,
"expected": 3,
"needs attention": 4,
"urgent improvement": 5,
}
# Column name priority for Report Card fields (best-guess names; Ofsted may vary)
RC_COLUMN_PRIORITY = {
"rc_safeguarding": [
"Safeguarding",
"safeguarding",
"Safeguarding standards",
],
"rc_inclusion": [
"Inclusion",
"inclusion",
],
"rc_curriculum_teaching": [
"Curriculum and teaching",
"curriculum_and_teaching",
"Curriculum & teaching",
],
"rc_achievement": [
"Achievement",
"achievement",
],
"rc_attendance_behaviour": [
"Attendance and behaviour",
"attendance_and_behaviour",
"Attendance & behaviour",
],
"rc_personal_development": [
"Personal development and well-being",
"Personal development and wellbeing",
"personal_development_and_wellbeing",
"Personal development & well-being",
],
"rc_leadership_governance": [
"Leadership and governance",
"leadership_and_governance",
"Leadership & governance",
],
"rc_early_years": [
"Early years",
"early_years",
"Early years provision",
],
"rc_sixth_form": [
"Sixth form",
"sixth_form",
"Sixth form in schools",
],
}
DEST_DIR = SUPPLEMENTARY_DIR / "ofsted"
def _discover_csv_url() -> str | None:
"""Scrape the GOV.UK page for the most recent CSV/ZIP link."""
try:
resp = requests.get(GOV_UK_PAGE, timeout=30)
resp.raise_for_status()
# Look for links to assets.publishing.service.gov.uk CSV or ZIP files
pattern = r'href="(https://assets\.publishing\.service\.gov\.uk[^"]+\.(?:csv|zip))"'
urls = re.findall(pattern, resp.text, re.IGNORECASE)
if urls:
return urls[0]
except Exception as e:
print(f" Warning: could not scrape GOV.UK page: {e}")
return None
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = _discover_csv_url()
if not url:
raise RuntimeError(
"Could not discover Ofsted MI download URL. "
"Visit https://www.gov.uk/government/statistical-data-sets/"
"monthly-management-information-ofsteds-school-inspections-outcomes "
"to get the latest URL and update MANUAL_URL in ofsted.py"
)
filename = url.split("/")[-1]
dest_file = dest / filename
if dest_file.exists():
print(f" Ofsted: {filename} already exists, skipping download.")
return dest_file
print(f" Ofsted: downloading {url} ...")
resp = requests.get(url, timeout=120, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" Ofsted: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
return dest_file
def _parse_grade(val) -> int | None:
if pd.isna(val):
return None
key = str(val).strip()
return GRADE_MAP.get(key)
def _parse_rc_grade(val) -> int | None:
"""Parse a Report Card grade text to integer 15."""
if pd.isna(val):
return None
key = str(val).strip().lower()
return RC_GRADE_MAP.get(key)
def _parse_safeguarding(val) -> bool | None:
"""Parse safeguarding 'Met'/'Not met' to boolean."""
if pd.isna(val):
return None
s = str(val).strip().lower()
if s == "met":
return True
if s in ("not met", "not_met"):
return False
return None
def _parse_date(val) -> date | None:
if pd.isna(val):
return None
for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d %B %Y"):
try:
return datetime.strptime(str(val).strip(), fmt).date()
except ValueError:
pass
return None
def _framework_for_row(row) -> str | None:
"""Determine inspection framework for a single school row.
Check RC columns first — if any have a value, it's a Report Card inspection.
Fall back to OEIF columns. If neither has data, the school has no graded
inspection on record (return None).
"""
rc_check_cols = [
"rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
"rc_attendance_behaviour", "rc_personal_development",
"rc_leadership_governance", "rc_safeguarding",
]
for col in rc_check_cols:
val = row.get(col)
if val is not None and not (isinstance(val, float) and pd.isna(val)):
return "ReportCard"
oeif_check_cols = ["overall_effectiveness", "quality_of_education"]
for col in oeif_check_cols:
val = row.get(col)
if val is not None and not (isinstance(val, float) and pd.isna(val)):
return "OEIF"
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv")) + sorted(dest.glob("*.zip"))
if not files:
raise FileNotFoundError(f"No Ofsted MI file found in {dest}")
path = files[-1]
print(f" Ofsted: loading {path} ...")
def _find_header_row(filepath, encoding="latin-1"):
"""Scan up to 10 rows to find the one containing a URN column."""
for i in range(10):
peek = pd.read_csv(filepath, encoding=encoding, header=i, nrows=0)
if any(str(c).strip() in ("URN", "Urn", "urn") for c in peek.columns):
return i
return 0
if str(path).endswith(".zip"):
import zipfile, io
with zipfile.ZipFile(path) as z:
csv_names = [n for n in z.namelist() if n.endswith(".csv")]
if not csv_names:
raise ValueError("No CSV found inside Ofsted ZIP")
# Extract to a temp file so we can scan for the header row
import tempfile, os
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
tmp.write(z.read(csv_names[0]))
tmp_path = tmp.name
try:
hdr = _find_header_row(tmp_path)
df = pd.read_csv(tmp_path, encoding="latin-1", low_memory=False, header=hdr)
finally:
os.unlink(tmp_path)
else:
hdr = _find_header_row(path)
df = pd.read_csv(path, encoding="latin-1", low_memory=False, header=hdr)
# Normalise OEIF column names: for each target field pick the first source column present
available = set(df.columns)
for target, sources in COLUMN_PRIORITY.items():
for src in sources:
if src in available:
df.rename(columns={src: target}, inplace=True)
break
# Normalise Report Card column names (if present)
available = set(df.columns)
for target, sources in RC_COLUMN_PRIORITY.items():
for src in sources:
if src in available:
df.rename(columns={src: target}, inplace=True)
break
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
# Only keep rows with a valid URN
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
inserted = updated = skipped = 0
with get_session() as session:
# Keep only the most recent inspection per URN
if "inspection_date" in df.columns:
df["_date_parsed"] = df["inspection_date"].apply(_parse_date)
df = df.sort_values("_date_parsed", ascending=False).groupby("urn").first().reset_index()
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
record = {
"urn": urn,
"framework": _framework_for_row(row),
"inspection_date": _parse_date(row.get("inspection_date")),
"publication_date": _parse_date(row.get("publication_date")),
"inspection_type": str(row.get("inspection_type", "")).strip() or None,
# OEIF fields
"overall_effectiveness": _parse_grade(row.get("overall_effectiveness")),
"quality_of_education": _parse_grade(row.get("quality_of_education")),
"behaviour_attitudes": _parse_grade(row.get("behaviour_attitudes")),
"personal_development": _parse_grade(row.get("personal_development")),
"leadership_management": _parse_grade(row.get("leadership_management")),
"early_years_provision": _parse_grade(row.get("early_years_provision")),
"previous_overall": None,
# Report Card fields
"rc_safeguarding_met": _parse_safeguarding(row.get("rc_safeguarding")),
"rc_inclusion": _parse_rc_grade(row.get("rc_inclusion")),
"rc_curriculum_teaching": _parse_rc_grade(row.get("rc_curriculum_teaching")),
"rc_achievement": _parse_rc_grade(row.get("rc_achievement")),
"rc_attendance_behaviour": _parse_rc_grade(row.get("rc_attendance_behaviour")),
"rc_personal_development": _parse_rc_grade(row.get("rc_personal_development")),
"rc_leadership_governance": _parse_rc_grade(row.get("rc_leadership_governance")),
"rc_early_years": _parse_rc_grade(row.get("rc_early_years")),
"rc_sixth_form": _parse_rc_grade(row.get("rc_sixth_form")),
}
session.execute(
text("""
INSERT INTO ofsted_inspections
(urn, framework, inspection_date, publication_date, inspection_type,
overall_effectiveness, quality_of_education, behaviour_attitudes,
personal_development, leadership_management, early_years_provision,
previous_overall,
rc_safeguarding_met, rc_inclusion, rc_curriculum_teaching,
rc_achievement, rc_attendance_behaviour, rc_personal_development,
rc_leadership_governance, rc_early_years, rc_sixth_form)
VALUES
(:urn, :framework, :inspection_date, :publication_date, :inspection_type,
:overall_effectiveness, :quality_of_education, :behaviour_attitudes,
:personal_development, :leadership_management, :early_years_provision,
:previous_overall,
:rc_safeguarding_met, :rc_inclusion, :rc_curriculum_teaching,
:rc_achievement, :rc_attendance_behaviour, :rc_personal_development,
:rc_leadership_governance, :rc_early_years, :rc_sixth_form)
ON CONFLICT (urn) DO UPDATE SET
previous_overall = ofsted_inspections.overall_effectiveness,
framework = EXCLUDED.framework,
inspection_date = EXCLUDED.inspection_date,
publication_date = EXCLUDED.publication_date,
inspection_type = EXCLUDED.inspection_type,
overall_effectiveness = EXCLUDED.overall_effectiveness,
quality_of_education = EXCLUDED.quality_of_education,
behaviour_attitudes = EXCLUDED.behaviour_attitudes,
personal_development = EXCLUDED.personal_development,
leadership_management = EXCLUDED.leadership_management,
early_years_provision = EXCLUDED.early_years_provision,
rc_safeguarding_met = EXCLUDED.rc_safeguarding_met,
rc_inclusion = EXCLUDED.rc_inclusion,
rc_curriculum_teaching = EXCLUDED.rc_curriculum_teaching,
rc_achievement = EXCLUDED.rc_achievement,
rc_attendance_behaviour = EXCLUDED.rc_attendance_behaviour,
rc_personal_development = EXCLUDED.rc_personal_development,
rc_leadership_governance = EXCLUDED.rc_leadership_governance,
rc_early_years = EXCLUDED.rc_early_years,
rc_sixth_form = EXCLUDED.rc_sixth_form
"""),
record,
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Processed {inserted} records...")
print(f" Ofsted: upserted {inserted} records")
return {"inserted": inserted, "updated": updated, "skipped": skipped}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
path = download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,229 +0,0 @@
"""
Ofsted Parent View open data downloader and loader.
Source: https://parentview.ofsted.gov.uk/open-data
Update: ~3 times/year (Spring, Autumn, Summer)
"""
import argparse
import re
import sys
from datetime import date, datetime
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "parent_view"
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
# Question column mapping — Parent View open data uses descriptive column headers
# Map any variant to our internal field names
QUESTION_MAP = {
# Q1 — happiness
"My child is happy at this school": "q_happy_pct",
"Happy": "q_happy_pct",
# Q2 — safety
"My child feels safe at this school": "q_safe_pct",
"Safe": "q_safe_pct",
# Q3 — bullying
"The school makes sure its pupils are well behaved": "q_behaviour_pct",
"Well Behaved": "q_behaviour_pct",
# Q4 — bullying dealt with (sometimes separate)
"My child has been bullied and the school dealt with the bullying quickly and effectively": "q_bullying_pct",
"Bullying": "q_bullying_pct",
# Q5 — curriculum info
"The school makes me aware of what my child will learn during the year": "q_communication_pct",
"Aware of learning": "q_communication_pct",
# Q6 — concerns dealt with
"When I have raised concerns with the school, they have been dealt with properly": "q_communication_pct",
# Q7 — child does well
"My child does well at this school": "q_progress_pct",
"Does well": "q_progress_pct",
# Q8 — teaching
"The teaching is good at this school": "q_teaching_pct",
"Good teaching": "q_teaching_pct",
# Q9 — progress info
"I receive valuable information from the school about my child's progress": "q_information_pct",
"Progress information": "q_information_pct",
# Q10 — curriculum breadth
"My child is taught a broad range of subjects": "q_curriculum_pct",
"Broad subjects": "q_curriculum_pct",
# Q11 — prepares for future
"The school prepares my child well for the future": "q_future_pct",
"Prepared for future": "q_future_pct",
# Q12 — leadership
"The school is led and managed effectively": "q_leadership_pct",
"Led well": "q_leadership_pct",
# Q13 — wellbeing
"The school supports my child's wider personal development": "q_wellbeing_pct",
"Personal development": "q_wellbeing_pct",
# Q14 — recommendation
"I would recommend this school to another parent": "q_recommend_pct",
"Recommend": "q_recommend_pct",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
# Scrape the open data page for the download link
try:
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
resp.raise_for_status()
pattern = r'href="([^"]+\.(?:xlsx|csv|zip))"'
urls = re.findall(pattern, resp.text, re.IGNORECASE)
if not urls:
raise RuntimeError("No download link found on Parent View open data page")
url = urls[0] if urls[0].startswith("http") else "https://parentview.ofsted.gov.uk" + urls[0]
except Exception as e:
raise RuntimeError(f"Could not discover Parent View download URL: {e}")
filename = url.split("/")[-1].split("?")[0]
dest_file = dest / filename
if dest_file.exists():
print(f" ParentView: {filename} already exists, skipping download.")
return dest_file
print(f" ParentView: downloading {url} ...")
resp = requests.get(url, timeout=120, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" ParentView: saved {dest_file}")
return dest_file
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
# Parent View open data has columns like "Q1 - Strongly agree %", "Q1 - Agree %"
strongly = row.get(f"{q_col_base} - Strongly agree %") or row.get(f"{q_col_base} - Strongly Agree %")
agree = row.get(f"{q_col_base} - Agree %")
try:
total = 0.0
if pd.notna(strongly):
total += float(strongly)
if pd.notna(agree):
total += float(agree)
return round(total, 1) if total > 0 else None
except (TypeError, ValueError):
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
files = sorted(dest.glob("*.xlsx")) + sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No Parent View file found in {dest}")
path = files[-1]
print(f" ParentView: loading {path} ...")
if str(path).endswith(".xlsx"):
df = pd.read_excel(path)
else:
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
# Normalise URN column
urn_col = next((c for c in df.columns if c.strip().upper() == "URN"), None)
if not urn_col:
raise ValueError(f"URN column not found. Columns: {list(df.columns)[:20]}")
df.rename(columns={urn_col: "urn"}, inplace=True)
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Try to find total responses column
resp_col = next((c for c in df.columns if "total" in c.lower() and "respon" in c.lower()), None)
inserted = 0
today = date.today()
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
total = int(row[resp_col]) if resp_col and pd.notna(row.get(resp_col)) else None
# Try to extract % positive per question from wide-format columns
# Parent View has numbered questions Q1Q12 (or Q1Q14 depending on year)
record = {
"urn": urn,
"survey_date": today,
"total_responses": total,
"q_happy_pct": _positive_pct(row, "Q1"),
"q_safe_pct": _positive_pct(row, "Q2"),
"q_behaviour_pct": _positive_pct(row, "Q3"),
"q_bullying_pct": _positive_pct(row, "Q4"),
"q_communication_pct": _positive_pct(row, "Q5"),
"q_progress_pct": _positive_pct(row, "Q7"),
"q_teaching_pct": _positive_pct(row, "Q8"),
"q_information_pct": _positive_pct(row, "Q9"),
"q_curriculum_pct": _positive_pct(row, "Q10"),
"q_future_pct": _positive_pct(row, "Q11"),
"q_leadership_pct": _positive_pct(row, "Q12"),
"q_wellbeing_pct": _positive_pct(row, "Q13"),
"q_recommend_pct": _positive_pct(row, "Q14"),
"q_sen_pct": None,
}
session.execute(
text("""
INSERT INTO ofsted_parent_view
(urn, survey_date, total_responses,
q_happy_pct, q_safe_pct, q_behaviour_pct, q_bullying_pct,
q_communication_pct, q_progress_pct, q_teaching_pct,
q_information_pct, q_curriculum_pct, q_future_pct,
q_leadership_pct, q_wellbeing_pct, q_recommend_pct, q_sen_pct)
VALUES
(:urn, :survey_date, :total_responses,
:q_happy_pct, :q_safe_pct, :q_behaviour_pct, :q_bullying_pct,
:q_communication_pct, :q_progress_pct, :q_teaching_pct,
:q_information_pct, :q_curriculum_pct, :q_future_pct,
:q_leadership_pct, :q_wellbeing_pct, :q_recommend_pct, :q_sen_pct)
ON CONFLICT (urn) DO UPDATE SET
survey_date = EXCLUDED.survey_date,
total_responses = EXCLUDED.total_responses,
q_happy_pct = EXCLUDED.q_happy_pct,
q_safe_pct = EXCLUDED.q_safe_pct,
q_behaviour_pct = EXCLUDED.q_behaviour_pct,
q_bullying_pct = EXCLUDED.q_bullying_pct,
q_communication_pct = EXCLUDED.q_communication_pct,
q_progress_pct = EXCLUDED.q_progress_pct,
q_teaching_pct = EXCLUDED.q_teaching_pct,
q_information_pct = EXCLUDED.q_information_pct,
q_curriculum_pct = EXCLUDED.q_curriculum_pct,
q_future_pct = EXCLUDED.q_future_pct,
q_leadership_pct = EXCLUDED.q_leadership_pct,
q_wellbeing_pct = EXCLUDED.q_wellbeing_pct,
q_recommend_pct = EXCLUDED.q_recommend_pct,
q_sen_pct = EXCLUDED.q_sen_pct
"""),
record,
)
inserted += 1
if inserted % 2000 == 0:
session.flush()
print(f" ParentView: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,132 +0,0 @@
"""
Phonics Screening Check downloader and loader.
Source: EES publication "phonics-screening-check-and-key-stage-1-assessments-england"
Update: Annual (September/October)
"""
import argparse
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import get_latest_csv_url, download_csv
DEST_DIR = SUPPLEMENTARY_DIR / "phonics"
PUBLICATION_SLUG = "phonics-screening-check-and-key-stage-1-assessments-england"
# Known column names in the phonics CSV (vary by year)
COLUMN_MAP = {
"URN": "urn",
"urn": "urn",
# Year 1 pass rate
"PPTA1": "year1_phonics_pct", # % meeting expected standard Y1
"PPTA1B": "year1_phonics_pct",
"PT_MET_PHON_Y1": "year1_phonics_pct",
"Y1_MET_EXPECTED_PCT": "year1_phonics_pct",
# Year 2 (re-takers)
"PPTA2": "year2_phonics_pct",
"PT_MET_PHON_Y2": "year2_phonics_pct",
"Y2_MET_EXPECTED_PCT": "year2_phonics_pct",
# Year label
"YEAR": "year",
"Year": "year",
}
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", ""}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
if not url:
raise RuntimeError(f"Could not find CSV URL for phonics publication")
filename = url.split("/")[-1].split("?")[0] or "phonics_latest.csv"
return download_csv(url, dest / filename)
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No phonics CSV found in {dest}")
path = files[-1]
print(f" Phonics: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Infer year from filename if not in data
year = None
import re
m = re.search(r"20(\d{2})", path.stem)
if m:
year = int("20" + m.group(1))
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
if not row_year:
continue
session.execute(
text("""
INSERT INTO phonics (urn, year, year1_phonics_pct, year2_phonics_pct)
VALUES (:urn, :year, :y1, :y2)
ON CONFLICT (urn, year) DO UPDATE SET
year1_phonics_pct = EXCLUDED.year1_phonics_pct,
year2_phonics_pct = EXCLUDED.year2_phonics_pct
"""),
{
"urn": urn,
"year": row_year,
"y1": _parse_pct(row.get("year1_phonics_pct")),
"y2": _parse_pct(row.get("year2_phonics_pct")),
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Phonics: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,150 +0,0 @@
"""
SEN (Special Educational Needs) primary need type breakdown.
Source: EES publication "special-educational-needs-in-england"
Update: Annual (September)
"""
import argparse
import re
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import get_latest_csv_url, download_csv
DEST_DIR = SUPPLEMENTARY_DIR / "sen_detail"
PUBLICATION_SLUG = "special-educational-needs-in-england"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
COLUMN_MAP = {
"URN": "urn",
"urn": "urn",
"YEAR": "year",
"Year": "year",
# Primary need types — DfE abbreviated codes
"PT_SPEECH": "primary_need_speech_pct", # SLCN
"PT_ASD": "primary_need_autism_pct", # ASD
"PT_MLD": "primary_need_mld_pct", # Moderate learning difficulty
"PT_SPLD": "primary_need_spld_pct", # Specific learning difficulty
"PT_SEMH": "primary_need_semh_pct", # Social, emotional, mental health
"PT_PHYSICAL": "primary_need_physical_pct", # Physical/sensory
"PT_OTHER": "primary_need_other_pct",
# Alternative naming
"SLCN_PCT": "primary_need_speech_pct",
"ASD_PCT": "primary_need_autism_pct",
"MLD_PCT": "primary_need_mld_pct",
"SPLD_PCT": "primary_need_spld_pct",
"SEMH_PCT": "primary_need_semh_pct",
"PHYSICAL_PCT": "primary_need_physical_pct",
"OTHER_PCT": "primary_need_other_pct",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
if not url:
url = get_latest_csv_url(PUBLICATION_SLUG)
if not url:
raise RuntimeError("Could not find CSV URL for SEN publication")
filename = url.split("/")[-1].split("?")[0] or "sen_latest.csv"
return download_csv(url, dest / filename)
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No SEN CSV found in {dest}")
path = files[-1]
print(f" SEN Detail: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
year = None
m = re.search(r"20(\d{2})", path.stem)
if m:
year = int("20" + m.group(1))
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
if not row_year:
continue
session.execute(
text("""
INSERT INTO sen_detail
(urn, year, primary_need_speech_pct, primary_need_autism_pct,
primary_need_mld_pct, primary_need_spld_pct, primary_need_semh_pct,
primary_need_physical_pct, primary_need_other_pct)
VALUES (:urn, :year, :speech, :autism, :mld, :spld, :semh, :physical, :other)
ON CONFLICT (urn, year) DO UPDATE SET
primary_need_speech_pct = EXCLUDED.primary_need_speech_pct,
primary_need_autism_pct = EXCLUDED.primary_need_autism_pct,
primary_need_mld_pct = EXCLUDED.primary_need_mld_pct,
primary_need_spld_pct = EXCLUDED.primary_need_spld_pct,
primary_need_semh_pct = EXCLUDED.primary_need_semh_pct,
primary_need_physical_pct = EXCLUDED.primary_need_physical_pct,
primary_need_other_pct = EXCLUDED.primary_need_other_pct
"""),
{
"urn": urn, "year": row_year,
"speech": _parse_pct(row.get("primary_need_speech_pct")),
"autism": _parse_pct(row.get("primary_need_autism_pct")),
"mld": _parse_pct(row.get("primary_need_mld_pct")),
"spld": _parse_pct(row.get("primary_need_spld_pct")),
"semh": _parse_pct(row.get("primary_need_semh_pct")),
"physical": _parse_pct(row.get("primary_need_physical_pct")),
"other": _parse_pct(row.get("primary_need_other_pct")),
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" SEN Detail: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

View File

@@ -1,70 +0,0 @@
"""
Data integrator HTTP server.
Kestra calls this server via HTTP tasks to trigger download/load operations.
"""
import importlib
import sys
import traceback
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
sys.path.insert(0, "/app/scripts")
app = FastAPI(title="SchoolCompare Data Integrator", version="1.0.0")
SOURCES = {
"ofsted", "gias", "parent_view",
"census", "admissions", "sen_detail",
"phonics", "idaci", "finance", "ks2",
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/run/{source}")
def run_source(source: str, action: str = "all"):
"""
Trigger a data source download and/or load.
action: "download" | "load" | "all"
"""
if source not in SOURCES:
raise HTTPException(status_code=404, detail=f"Unknown source '{source}'. Available: {sorted(SOURCES)}")
if action not in ("download", "load", "all"):
raise HTTPException(status_code=400, detail="action must be 'download', 'load', or 'all'")
try:
mod = importlib.import_module(f"sources.{source}")
result = {}
if action in ("download", "all"):
mod.download()
if action in ("load", "all"):
result = mod.load()
return {"source": source, "action": action, "result": result}
except Exception as e:
tb = traceback.format_exc()
raise HTTPException(status_code=500, detail={"error": str(e), "traceback": tb})
@app.post("/run-all")
def run_all(action: str = "all"):
"""Trigger all sources in sequence."""
results = {}
for source in sorted(SOURCES):
try:
mod = importlib.import_module(f"sources.{source}")
if action in ("download", "all"):
mod.download()
if action in ("load", "all"):
results[source] = mod.load()
except Exception as e:
results[source] = {"error": str(e)}
return results

View File

@@ -58,6 +58,23 @@
--transition: 0.2s ease;
--transition-slow: 0.4s ease;
/* Phase indicators */
--phase-primary: #5b8cbf;
--phase-primary-bg: rgba(91, 140, 191, 0.10);
--phase-primary-text: #3d6a99;
--phase-secondary: #9b6bb0;
--phase-secondary-bg: rgba(155, 107, 176, 0.10);
--phase-secondary-text: #7a4f93;
--phase-all-through: #7a9a6d;
--phase-all-through-bg: rgba(122, 154, 109, 0.10);
--phase-all-through-text: #5a7a4d;
--phase-post16: #c4915e;
--phase-post16-bg: rgba(196, 145, 94, 0.10);
--phase-post16-text: #9a6d3a;
--phase-nursery: #e0a0b0;
--phase-nursery-bg: rgba(224, 160, 176, 0.10);
--phase-nursery-text: #b06070;
}
* {

View File

@@ -23,11 +23,11 @@ const playfairDisplay = Playfair_Display({
export const metadata: Metadata = {
title: {
default: 'SchoolCompare | Compare Primary School Performance',
default: 'SchoolCompare | Compare School Performance',
template: '%s | SchoolCompare',
},
description: 'Compare primary school KS2 performance across England',
keywords: 'school comparison, KS2 results, primary school performance, England schools, SATs results',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
authors: [{ name: 'SchoolCompare' }],
manifest: '/manifest.json',
icons: {
@@ -37,15 +37,15 @@ export const metadata: Metadata = {
},
openGraph: {
type: 'website',
title: 'SchoolCompare | Compare Primary School Performance',
description: 'Compare primary school KS2 performance across England',
title: 'SchoolCompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
url: 'https://schoolcompare.co.uk',
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title: 'SchoolCompare | Compare Primary School Performance',
description: 'Compare primary school KS2 performance across England',
title: 'SchoolCompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
},
};

View File

@@ -11,15 +11,20 @@ interface HomePageProps {
search?: string;
local_authority?: string;
school_type?: string;
phase?: string;
page?: string;
postcode?: string;
radius?: string;
sort?: string;
gender?: string;
admissions_policy?: string;
has_sixth_form?: string;
}>;
}
export const metadata = {
title: 'Home',
description: 'Search and compare primary school KS2 performance across England',
description: 'Search and compare school performance across England',
};
// Force dynamic rendering (no static generation at build time)
@@ -38,7 +43,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
params.search ||
params.local_authority ||
params.school_type ||
params.postcode
params.phase ||
params.postcode ||
params.gender ||
params.admissions_policy ||
params.has_sixth_form
);
// Fetch data on server with error handling
@@ -52,10 +61,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
search: params.search,
local_authority: params.local_authority,
school_type: params.school_type,
phase: params.phase,
postcode: params.postcode,
radius,
page,
page_size: 50,
gender: params.gender,
admissions_policy: params.admissions_policy,
has_sixth_form: params.has_sixth_form,
});
} else {
// Empty state by default
@@ -65,7 +78,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return (
<HomeView
initialSchools={schoolsData}
filters={filtersData || { local_authorities: [], school_types: [], years: [] }}
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
totalSchools={dataInfo?.total_schools ?? null}
/>
);
@@ -76,7 +89,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return (
<HomeView
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
filters={{ local_authorities: [], school_types: [], years: [] }}
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
totalSchools={null}
/>
);

View File

@@ -12,22 +12,24 @@ interface RankingsPageProps {
metric?: string;
local_authority?: string;
year?: string;
phase?: string;
}>;
}
export const metadata: Metadata = {
title: 'School Rankings',
description: 'Top-ranked primary schools by KS2 performance across England',
keywords: 'school rankings, top schools, best schools, KS2 rankings, school league tables',
description: 'Top-ranked schools by SATs and GCSE performance across England',
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
};
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
const { metric: metricParam, local_authority, year: yearParam } = await searchParams;
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
const metric = metricParam || 'rwm_expected_pct';
const phase = phaseParam || 'primary';
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
const year = yearParam ? parseInt(yearParam) : undefined;
// Fetch rankings data with error handling
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
local_authority,
year,
limit: 100,
phase,
}),
fetchFilters(),
fetchMetrics(),
@@ -49,11 +52,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
return (
<RankingsView
rankings={rankingsResponse?.rankings || []}
filters={filtersResponse || { local_authorities: [], school_types: [], years: [] }}
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
metrics={metricsArray}
selectedMetric={metric}
selectedArea={local_authority}
selectedYear={year}
selectedPhase={phase}
/>
);
} catch (error) {
@@ -63,11 +67,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
return (
<RankingsView
rankings={[]}
filters={{ local_authorities: [], school_types: [], years: [] }}
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
metrics={[]}
selectedMetric={metric}
selectedArea={local_authority}
selectedYear={year}
selectedPhase={phase}
/>
);
}

View File

@@ -0,0 +1,180 @@
/**
* Individual School Page (SSR)
* Dynamic route for school details with full SEO optimization
* URL format: /school/138267-school-name-here
*/
import { fetchSchoolDetails } from '@/lib/api';
import { notFound, redirect } from 'next/navigation';
import { SchoolDetailView } from '@/components/SchoolDetailView';
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
import type { Metadata } from 'next';
interface SchoolPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
const { slug } = await params;
const urn = parseSchoolSlug(slug);
if (!urn || urn < 100000 || urn > 999999) {
return {
title: 'School Not Found',
};
}
try {
const data = await fetchSchoolDetails(urn);
const { school_info } = data;
const canonicalPath = schoolUrl(urn, school_info.school_name);
const phaseStr = (school_info.phase ?? '').toLowerCase();
const isAllThrough = phaseStr === 'all-through';
const isSecondary = !isAllThrough && (
phaseStr.includes('secondary')
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null)
);
const la = school_info.local_authority ? ` in ${school_info.local_authority}` : '';
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
const description = isAllThrough
? `View KS2 SATs and GCSE results for ${school_info.school_name}${la}. All-through school covering primary and secondary education.`
: isSecondary
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${la}.`
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${la}. Compare reading, writing, and maths results.`;
return {
title,
description,
keywords: isAllThrough
? `${school_info.school_name}, KS2 results, GCSE results, all-through school, ${school_info.local_authority}, SATs, Attainment 8`
: isSecondary
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
openGraph: {
title,
description,
type: 'website',
url: `https://schoolcompare.co.uk${canonicalPath}`,
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title,
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
},
};
} catch {
return {
title: 'School Not Found',
};
}
}
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function SchoolPage({ params }: SchoolPageProps) {
const { slug } = await params;
const urn = parseSchoolSlug(slug);
// Validate URN format
if (!urn || urn < 100000 || urn > 999999) {
notFound();
}
// Fetch school data
let data;
try {
data = await fetchSchoolDetails(urn);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
if (slug !== canonicalSlug) {
redirect(`/school/${canonicalSlug}`);
}
const phaseStr = (school_info.phase ?? '').toLowerCase();
const isAllThrough = phaseStr === 'all-through';
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
const isSecondary = !isAllThrough && (
phaseStr.includes('secondary')
|| yearly_data.some((d: any) => d.attainment_8_score != null)
);
// Generate JSON-LD structured data for SEO
const structuredData = {
'@context': 'https://schema.org',
'@type': 'EducationalOrganization',
name: school_info.school_name,
identifier: school_info.urn.toString(),
...(school_info.address && {
address: {
'@type': 'PostalAddress',
streetAddress: school_info.address,
addressLocality: school_info.local_authority || undefined,
postalCode: school_info.postcode || undefined,
addressCountry: 'GB',
},
}),
...(school_info.latitude && school_info.longitude && {
geo: {
'@type': 'GeoCoordinates',
latitude: school_info.latitude,
longitude: school_info.longitude,
},
}),
...(school_info.school_type && {
additionalType: school_info.school_type,
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{isSecondary ? (
<SecondarySchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
) : (
<SchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
)}
</>
);
}

View File

@@ -1,130 +0,0 @@
/**
* Individual School Page (SSR)
* Dynamic route for school details with full SEO optimization
*/
import { fetchSchoolDetails } from '@/lib/api';
import { notFound } from 'next/navigation';
import { SchoolDetailView } from '@/components/SchoolDetailView';
import type { Metadata } from 'next';
interface SchoolPageProps {
params: Promise<{ urn: string }>;
}
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
if (isNaN(urn) || urn < 100000 || urn > 999999) {
return {
title: 'School Not Found',
};
}
try {
const data = await fetchSchoolDetails(urn);
const { school_info } = data;
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
const description = `View KS2 performance data, results, and statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}. Compare reading, writing, and maths results.`;
return {
title,
description,
keywords: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
openGraph: {
title,
description,
type: 'website',
url: `https://schoolcompare.co.uk/school/${urn}`,
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title,
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk/school/${urn}`,
},
};
} catch {
return {
title: 'School Not Found',
};
}
}
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function SchoolPage({ params }: SchoolPageProps) {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
// Validate URN format
if (isNaN(urn) || urn < 100000 || urn > 999999) {
notFound();
}
// Fetch school data
let data;
try {
data = await fetchSchoolDetails(urn);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
// Generate JSON-LD structured data for SEO
const structuredData = {
'@context': 'https://schema.org',
'@type': 'EducationalOrganization',
name: school_info.school_name,
identifier: school_info.urn.toString(),
...(school_info.address && {
address: {
'@type': 'PostalAddress',
streetAddress: school_info.address,
addressLocality: school_info.local_authority || undefined,
postalCode: school_info.postcode || undefined,
addressCountry: 'GB',
},
}),
...(school_info.latitude && school_info.longitude && {
geo: {
'@type': 'GeoCoordinates',
latitude: school_info.latitude,
longitude: school_info.longitude,
},
}),
...(school_info.school_type && {
additionalType: school_info.school_type,
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<SchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
</>
);
}

View File

@@ -1,54 +0,0 @@
/**
* Dynamic Sitemap Generation
* Generates sitemap with all school pages and main routes
*/
import { MetadataRoute } from 'next';
import { fetchSchools } from '@/lib/api';
const BASE_URL = 'https://schoolcompare.co.uk';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${BASE_URL}/compare`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${BASE_URL}/rankings`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
];
// Fetch all schools (in batches if necessary)
try {
const schoolsData = await fetchSchools({
page: 1,
page_size: 10000, // Fetch all schools
});
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
url: `${BASE_URL}/school/${school.urn}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.6,
}));
return [...staticPages, ...schoolPages];
} catch (error) {
console.error('Failed to generate sitemap:', error);
// Return just static pages if school fetch fails
return staticPages;
}
}

View File

@@ -18,7 +18,7 @@ import {
ChartOptions,
} from 'chart.js';
import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS } from '@/lib/utils';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
// Register Chart.js components
ChartJS.register(
@@ -68,7 +68,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
});
const chartData = {
labels: years.map(String),
labels: years.map(formatAcademicYear),
datasets,
};

View File

@@ -23,18 +23,12 @@
flex-direction: column;
gap: 0;
padding: 1rem 1.25rem;
background: var(--bg-accent, #1a1612);
color: var(--text-inverse, #faf7f2);
background: var(--bg-primary, #faf7f2);
color: var(--text-primary, #2c2420);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(26, 22, 18, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 260px;
}
.toastInfo {
display: flex;
align-items: center;
gap: 0.75rem;
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
border: 1px solid var(--border-color, #e8ddd4);
min-width: 280px;
}
.toastBadge {
@@ -48,38 +42,7 @@
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
}
.toastText {
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
}
.toastActions {
display: flex;
align-items: center;
gap: 0.75rem;
padding-top: 0.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btnCompare {
background: white;
color: var(--bg-accent, #1a1612);
padding: 0.6rem 1.25rem;
border-radius: 25px;
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: transform 0.2s ease, background-color 0.2s ease;
white-space: nowrap;
}
.btnCompare:hover {
transform: translateY(-1px);
background: var(--bg-secondary, #f3ede4);
flex-shrink: 0;
}
.toastHeader {
@@ -93,10 +56,19 @@
margin-bottom: 0;
}
.toastTitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #2c2420);
}
.collapseBtn {
background: none;
border: none;
color: rgba(250, 247, 242, 0.6);
color: var(--text-muted, #8a7a72);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
@@ -107,16 +79,7 @@
}
.collapseBtn:hover {
color: var(--text-inverse, #faf7f2);
}
.toastTitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-inverse, #faf7f2);
color: var(--text-primary, #2c2420);
}
.schoolList {
@@ -124,8 +87,6 @@
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
max-height: 120px;
overflow-y: auto;
}
.schoolItem {
@@ -133,14 +94,14 @@
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.25rem 0.375rem;
background: rgba(255, 255, 255, 0.08);
padding: 0.3rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: var(--radius-sm, 4px);
}
.schoolName {
font-size: 0.8rem;
color: var(--text-inverse, #faf7f2);
color: var(--text-primary, #2c2420);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -150,7 +111,7 @@
.removeSchoolBtn {
background: none;
border: none;
color: rgba(250, 247, 242, 0.5);
color: var(--text-muted, #8a7a72);
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
@@ -160,7 +121,50 @@
}
.removeSchoolBtn:hover {
color: var(--text-inverse, #faf7f2);
color: var(--accent-coral, #e07256);
}
.toastActions {
display: flex;
align-items: center;
gap: 0.75rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border-color, #e8ddd4);
}
.btnClearAll {
background: none;
border: none;
color: var(--text-muted, #8a7a72);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0.25rem;
transition: color 0.2s ease;
white-space: nowrap;
}
.btnClearAll:hover {
color: var(--accent-coral, #e07256);
}
.btnCompare {
flex: 1;
background: var(--accent-coral, #e07256);
color: white;
padding: 0.6rem 1.25rem;
border-radius: 25px;
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
text-align: center;
transition: transform 0.2s ease, background-color 0.2s ease;
white-space: nowrap;
}
.btnCompare:hover {
transform: translateY(-1px);
background: var(--accent-coral-dark, #c9614a);
}
@media (max-width: 640px) {

View File

@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
export function ComparisonToast() {
const { selectedSchools, clearAll, removeSchool } = useComparison();
const [mounted, setMounted] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(true);
const pathname = usePathname();
useEffect(() => {
@@ -62,7 +62,7 @@ export function ComparisonToast() {
))}
</div>
<div className={styles.toastActions}>
<button onClick={clearAll} className="btn btn-tertiary btn-sm" style={{ color: 'rgba(250,247,242,0.7)', borderColor: 'rgba(255,255,255,0.15)' }}>Clear all</button>
<button onClick={clearAll} className={styles.btnClearAll}>Clear all</button>
<Link href="/compare" className={styles.btnCompare}>Compare Now</Link>
</div>
</>

View File

@@ -31,6 +31,47 @@
}
/* Phase Tabs */
.phaseTabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.phaseTab {
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.phaseTab:not(:last-child) {
border-right: 1px solid var(--border-color, #e5dfd5);
}
.phaseTab:hover {
background: var(--bg-secondary, #f3ede4);
}
.phaseTabActive {
background: var(--accent-coral, #e07256);
color: white;
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral, #e07256);
}
/* Metric Selector */
.metricSelector {
background: var(--bg-card, white);

View File

@@ -1,6 +1,6 @@
/**
* ComparisonView Component
* Client-side comparison interface with charts and tables
* Client-side comparison interface with phase tabs, charts, and tables
*/
'use client';
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
import { fetchComparison } from '@/lib/api';
import styles from './ComparisonView.module.css';
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
const SECONDARY_CATEGORIES = ['gcse'];
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null;
initialUrns: number[];
@@ -39,6 +58,7 @@ export function ComparisonView({
const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData);
const [shareConfirm, setShareConfirm] = useState(false);
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Seed context from initialData when component mounts and localStorage is empty
useEffect(() => {
@@ -84,6 +104,37 @@ export function ComparisonView({
}
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
// Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => {
const info = comparisonData?.[school.urn]?.school_info;
if (info?.attainment_8_score != null) return 'secondary';
if (info?.rwm_expected_pct != null) return 'primary';
// Fallback: check yearly data
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
return 'primary';
};
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
// Auto-select tab with more schools
useEffect(() => {
if (comparisonData && selectedSchools.length > 0) {
if (secondarySchools.length > primarySchools.length) {
setComparePhase('secondary');
} else {
setComparePhase('primary');
}
}
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
setComparePhase(phase);
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
setSelectedMetric(defaultMetric);
};
const handleMetricChange = (metric: string) => {
setSelectedMetric(metric);
};
@@ -100,6 +151,12 @@ export function ComparisonView({
} catch { /* fallback: do nothing */ }
};
const isPrimary = comparePhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
const activeSchools = isPrimary ? primarySchools : secondarySchools;
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
@@ -129,10 +186,20 @@ export function ComparisonView({
);
}
// Build filtered comparison data for active phase
const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) {
activeSchools.forEach(s => {
if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn];
}
});
}
// Get years for table
const years =
comparisonData && Object.keys(comparisonData).length > 0
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
Object.keys(activeComparisonData).length > 0
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: [];
return (
@@ -158,6 +225,33 @@ export function ComparisonView({
</div>
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
{activeSchools.length === 0 ? (
<EmptyState
title={`No ${comparePhase} schools in your comparison`}
message={`Add ${comparePhase} schools from search results to compare them here.`}
action={{
label: '+ Add Schools',
onClick: () => setIsModalOpen(true),
}}
/>
) : (
<>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
@@ -169,46 +263,17 @@ export function ComparisonView({
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
<optgroup label="Expected Standard">
{metrics.filter(m => m.category === 'expected').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Higher Standard">
{metrics.filter(m => m.category === 'higher').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Progress Scores">
{metrics.filter(m => m.category === 'progress').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Average Scores">
{metrics.filter(m => m.category === 'average').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Gender Performance">
{metrics.filter(m => m.category === 'gender').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Equity (Disadvantaged)">
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="School Context">
{metrics.filter(m => m.category === 'context').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="3-Year Trends">
{metrics.filter(m => m.category === '3yr').map((metric) => (
{optgroups.map(({ label, category }) => {
const groupMetrics = filteredMetrics.filter(m => m.category === category);
if (groupMetrics.length === 0) return null;
return (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
@@ -218,14 +283,14 @@ export function ComparisonView({
{/* Progress score explanation */}
{selectedMetric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
</p>
)}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{selectedSchools.map((school, index) => (
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolCard}
@@ -240,7 +305,7 @@ export function ComparisonView({
×
</button>
<h2 className={styles.schoolName}>
<a href={`/school/${school.urn}`}>{school.school_name}</a>
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
</h2>
<div className={styles.schoolMeta}>
{school.local_authority && (
@@ -252,7 +317,7 @@ export function ComparisonView({
</div>
{/* Latest metric value */}
{comparisonData && comparisonData[school.urn] && (
{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] }}>
@@ -268,7 +333,7 @@ export function ComparisonView({
}}
/>
{(() => {
const yearlyData = comparisonData[school.urn].yearly_data;
const yearlyData = activeComparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1];
@@ -276,7 +341,6 @@ export function ComparisonView({
if (value === null || value === undefined) return '-';
// Format based on metric type
if (selectedMetric.includes('progress')) {
return formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
@@ -294,25 +358,25 @@ export function ComparisonView({
</section>
{/* Comparison Chart */}
{comparisonData && Object.keys(comparisonData).length > 0 ? (
{Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={comparisonData}
comparisonData={activeComparisonData}
metric={selectedMetric}
metricLabel={metricLabel}
/>
</div>
</section>
) : selectedSchools.length > 0 ? (
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Table */}
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
@@ -320,7 +384,7 @@ export function ComparisonView({
<thead>
<tr>
<th>Year</th>
{selectedSchools.map((school) => (
{activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
@@ -328,9 +392,9 @@ export function ComparisonView({
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{year}</td>
{selectedSchools.map((school) => {
const schoolData = comparisonData[school.urn];
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
{activeSchools.map((school) => {
const schoolData = activeComparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year);
@@ -342,7 +406,6 @@ export function ComparisonView({
return <td key={school.urn}>-</td>;
}
// Format based on metric type
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
@@ -361,6 +424,8 @@ export function ComparisonView({
</div>
</section>
)}
</>
)}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />

View File

@@ -32,8 +32,12 @@
padding: 1.25rem 2.5rem;
}
.heroMode .searchSection {
margin-bottom: 0;
}
.searchSection {
margin-bottom: 1rem;
margin-bottom: 0;
}
.omniBoxContainer {
@@ -84,30 +88,49 @@
.filters {
display: flex;
gap: 0.75rem;
gap: 0.625rem;
flex-wrap: wrap;
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border-color, #e5dfd5);
}
.filterSelect {
appearance: none;
-webkit-appearance: none;
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
font-size: 0.95rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
background: var(--bg-card, white);
min-width: 180px;
padding: 0.625rem 2.25rem 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 8px;
background-color: var(--bg-card, white);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
background-size: 10px 6px;
cursor: pointer;
outline: none;
color: var(--text-primary, #1a1612);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.filterSelect:hover {
border-color: var(--text-muted, #8a847a);
}
.filterSelect:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
}
.clearButton {
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
padding: 0.4rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 999px;
}
@media (max-width: 768px) {
@@ -130,6 +153,19 @@
.filterSelect {
min-width: 100%;
}
.controlsRow {
gap: 0.5rem;
}
.controlsRow .advancedToggle {
margin-left: 0;
}
.controlSelect {
flex: 1;
min-width: 140px;
}
}
.radiusWrapper {
@@ -140,17 +176,104 @@
}
.radiusLabel {
font-size: 0.875rem;
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #5a554d);
white-space: nowrap;
}
.radiusSelect {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0ddd8);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
/* ── Controls row (radius + phase + advanced toggle) ─── */
.controlsRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.875rem;
padding-top: 0.875rem;
border-top: 1px solid var(--border-color, #e5dfd5);
}
.controlsRow .advancedToggle {
margin-left: auto;
}
.radiusControl {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
/* Pill-style inline filter controls (radius + phase) */
.controlSelect {
appearance: none;
-webkit-appearance: none;
padding: 0.4rem 2rem 0.4rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 999px;
background-color: var(--bg-card, white);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
background-size: 10px 6px;
color: var(--text-primary, #1a1612);
cursor: pointer;
outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
}
.controlSelect:hover {
border-color: var(--text-muted, #8a847a);
background-color: var(--bg-secondary, #f8f4ef);
}
.controlSelect:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
}
/* ── Advanced filters toggle ─────────────────────────── */
.advancedToggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: none;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 999px;
padding: 0.4rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #5a554d);
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
white-space: nowrap;
}
.advancedToggle:hover {
border-color: var(--text-muted, #8a847a);
background-color: var(--bg-secondary, #f8f4ef);
color: var(--text-primary, #1a1612);
}
.chevronDown,
.chevronUp {
display: inline-block;
width: 0;
height: 0;
border-left: 3.5px solid transparent;
border-right: 3.5px solid transparent;
}
.chevronDown {
border-top: 4.5px solid currentColor;
}
.chevronUp {
border-bottom: 4.5px solid currentColor;
}

View File

@@ -3,15 +3,16 @@
import { useState, useCallback, useTransition, useRef, useEffect } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { isValidPostcode } from '@/lib/utils';
import type { Filters } from '@/lib/types';
import type { Filters, ResultFilters } from '@/lib/types';
import styles from './FilterBar.module.css';
interface FilterBarProps {
filters: Filters;
isHero?: boolean;
resultFilters?: ResultFilters;
}
export function FilterBar({ filters, isHero }: FilterBarProps) {
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -27,10 +28,23 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || '';
const currentPhase = searchParams.get('phase') || '';
const currentGender = searchParams.get('gender') || '';
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
// Count active dropdown filters (not search/postcode, not phase since it's always visible)
const activeDropdownFilters = [currentLA, currentType, currentGender, currentAdmissionsPolicy, currentHasSixthForm].filter(Boolean);
const hasActiveDropdownFilters = activeDropdownFilters.length > 0;
const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters);
// Auto-open if filters become active (e.g. URL change)
useEffect(() => {
if (hasActiveDropdownFilters) setFiltersOpen(true);
}, [hasActiveDropdownFilters]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Focus search on '/' or Ctrl+K, but not when typing in an input
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
document.activeElement?.tagName !== 'INPUT' &&
document.activeElement?.tagName !== 'TEXTAREA' &&
@@ -86,7 +100,16 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
});
};
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode;
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm;
// Use result-scoped filter values when available, fall back to global
const laOptions = resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = resultFilters?.school_types ?? filters.school_types;
const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
const admissionsPolicyOptions = resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
const isSecondaryMode = currentPhase === 'secondary' || genderOptions.length > 0;
return (
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
@@ -104,25 +127,59 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
{isPending ? <div className={styles.spinner}></div> : 'Search'}
</button>
</div>
</form>
{!isHero && (
<>
<div className={styles.controlsRow}>
{currentPostcode && (
<div className={styles.radiusWrapper}>
<div className={styles.radiusControl}>
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })}
className={styles.radiusSelect}
className={styles.controlSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
</select>
</div>
)}
</form>
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
className={styles.controlSelect}
disabled={isPending}
>
<option value="">All Phases</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
>
Advanced{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
</button>
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear
</button>
)}
</div>
{filtersOpen && (
<div className={styles.filters}>
<select
value={currentLA}
@@ -131,10 +188,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All Local Authorities</option>
{filters.local_authorities.map((la) => (
<option key={la} value={la}>
{la}
</option>
{laOptions.map((la) => (
<option key={la} value={la}>{la}</option>
))}
</select>
@@ -145,19 +200,57 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
disabled={isPending}
>
<option value="">All School Types</option>
{filters.school_types.map((type) => (
<option key={type} value={type}>
{type}
</option>
{typeOptions.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear Filters
</button>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<select
value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option>
))}
</select>
)}
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
</select>
{admissionsPolicyOptions.length > 0 && (
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
)}
</>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -15,7 +15,7 @@ export function Footer() {
<div className={styles.section}>
<h3 className={styles.title}>SchoolCompare</h3>
<p className={styles.description}>
Compare primary schools across England.
Compare primary and secondary schools across England.
</p>
</div>

View File

@@ -33,33 +33,6 @@
}
}
.locationBannerWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.locationBanner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent-teal-bg);
border: 1px solid rgba(45, 125, 125, 0.25);
border-radius: 8px;
font-size: 0.875rem;
color: var(--accent-teal, #2d7d7d);
font-weight: 500;
}
.locationIcon {
font-size: 1.25rem;
color: var(--accent-teal, #2d7d7d);
}
/* View Toggle */
.viewToggle {
display: flex;
@@ -111,7 +84,9 @@
display: grid;
grid-template-columns: 1fr 340px;
gap: 1rem;
height: 480px;
height: calc(100vh - 280px);
min-height: 520px;
max-height: 800px;
}
.mapContainer {
@@ -308,16 +283,14 @@
}
@media (max-width: 768px) {
.locationBannerWrapper {
.resultsHeader {
flex-direction: column;
align-items: stretch;
margin-bottom: 0.75rem;
align-items: flex-start;
}
.locationBanner {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
border-radius: 6px;
.resultsHeaderActions {
width: 100%;
justify-content: space-between;
}
.viewToggle {
@@ -509,6 +482,13 @@
padding: 0 0 1rem;
}
.resultsHeaderActions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.sortSelect {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0ddd8);
@@ -549,3 +529,21 @@
.chipRemove:hover {
color: var(--text-primary);
}
.loadMoreSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0;
}
.loadMoreCount {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
margin: 0;
}
.loadMoreButton {
min-width: 160px;
}

View File

@@ -5,15 +5,17 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { FilterBar } from './FilterBar';
import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap';
import { Pagination } from './Pagination';
import { EmptyState } from './EmptyState';
import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
import styles from './HomeView.module.css';
interface HomeViewProps {
@@ -24,23 +26,94 @@ interface HomeViewProps {
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
const [sortOrder, setSortOrder] = useState<string>('default');
const sortOrder = searchParams.get('sort') || 'default';
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
const [currentPage, setCurrentPage] = useState(initialSchools.page);
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
const [mapSchools, setMapSchools] = useState<School[]>([]);
const [isLoadingMap, setIsLoadingMap] = useState(false);
const prevSearchParamsRef = useRef(searchParams.toString());
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
const currentPhase = searchParams.get('phase') || '';
const secondaryCount = allSchools.filter(s => s.attainment_8_score != null).length;
const primaryCount = allSchools.filter(s => s.rwm_expected_pct != null).length;
const isSecondaryView = currentPhase.toLowerCase().includes('secondary')
|| (!currentPhase && secondaryCount > primaryCount);
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
// Reset pagination state when search params change
useEffect(() => {
const newParamsStr = searchParams.toString();
if (newParamsStr !== prevSearchParamsRef.current) {
prevSearchParamsRef.current = newParamsStr;
setAllSchools(initialSchools.schools);
setCurrentPage(initialSchools.page);
setHasMore(initialSchools.total_pages > 1);
setMapSchools([]);
}
}, [searchParams, initialSchools]);
// Close bottom sheet if we change views or search
useEffect(() => {
setSelectedMapSchool(null);
}, [resultsView, searchParams]);
const sortedSchools = [...initialSchools.schools].sort((a, b) => {
// Fetch all schools within radius when map view is active
useEffect(() => {
if (resultsView !== 'map' || !isLocationSearch) return;
setIsLoadingMap(true);
const params: Record<string, any> = {};
searchParams.forEach((value, key) => { params[key] = value; });
params.page = 1;
params.page_size = 500;
fetchSchools(params, { cache: 'no-store' })
.then(r => setMapSchools(r.schools))
.catch(() => setMapSchools(initialSchools.schools))
.finally(() => setIsLoadingMap(false));
}, [resultsView, searchParams]);
// Fetch LA averages when secondary or mixed schools are visible
useEffect(() => {
if (!isSecondaryView && !isMixedView) return;
fetchLAaverages({ cache: 'force-cache' })
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
.catch(() => {});
}, [isSecondaryView, isMixedView]);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
try {
const params: Record<string, any> = {};
searchParams.forEach((value, key) => { params[key] = value; });
params.page = currentPage + 1;
params.page_size = initialSchools.page_size;
const response = await fetchSchools(params, { cache: 'no-store' });
setAllSchools(prev => [...prev, ...response.schools]);
setCurrentPage(response.page);
setHasMore(response.page < response.total_pages);
} catch {
// silently ignore
} finally {
setIsLoadingMore(false);
}
};
const sortedSchools = [...allSchools].sort((a, b) => {
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity);
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
return 0;
@@ -51,20 +124,21 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
{/* Combined Hero + Search and Filters */}
{!isSearchActive && (
<div className={styles.heroSection}>
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
<h1 className={styles.heroTitle}>Find Local Schools</h1>
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</p>
</div>
)}
<FilterBar
filters={filters}
isHero={!isSearchActive}
resultFilters={initialSchools.result_filters}
/>
{/* Discovery section shown on landing page before any search */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.discoverySection}>
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary schools across England</p>}
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary and secondary schools across England</p>}
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
<div className={styles.quickSearches}>
<span className={styles.quickSearchLabel}>Quick searches:</span>
@@ -75,16 +149,27 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
</div>
)}
{/* Location Info Banner with View Toggle */}
{isLocationSearch && initialSchools.location_info && (
<div className={styles.locationBannerWrapper}>
<div className={styles.locationBanner}>
<span>
Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '}
<strong>{initialSchools.location_info.postcode}</strong>
</span>
{/* Results Section */}
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
<h2>Featured Schools</h2>
<p className={styles.sectionDescription}>
Explore schools from across England
</p>
</div>
{initialSchools.schools.length > 0 && (
)}
{hasSearch && (
<div className={styles.resultsHeader}>
<h2 aria-live="polite" aria-atomic="true">
{isLocationSearch && initialSchools.location_info
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
}
</h2>
<div className={styles.resultsHeaderActions}>
{isLocationSearch && initialSchools.schools.length > 0 && (
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
@@ -112,33 +197,30 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
</button>
</div>
)}
</div>
)}
{/* Results Section */}
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
<h2>Featured Schools</h2>
<p className={styles.sectionDescription}>
Explore schools from across England
</p>
</div>
)}
{hasSearch && resultsView === 'list' && (
<div className={styles.resultsHeader}>
<h2 aria-live="polite" aria-atomic="true">
{initialSchools.total.toLocaleString()} school
{initialSchools.total !== 1 ? 's' : ''} found
</h2>
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
{resultsView === 'list' && (
<select
value={sortOrder}
onChange={e => {
const params = new URLSearchParams(searchParams);
if (e.target.value === 'default') {
params.delete('sort');
} else {
params.set('sort', e.target.value);
}
router.push(`${pathname}?${params.toString()}`);
}}
className={styles.sortSelect}
>
<option value="default">Sort: Relevance</option>
<option value="rwm_desc">Highest R, W &amp; M %</option>
<option value="rwm_asc">Lowest R, W &amp; M %</option>
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest R, W &amp; M %</option>}
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest R, W &amp; M %</option>}
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
{isLocationSearch && <option value="distance">Nearest first</option>}
<option value="name_asc">Name AZ</option>
</select>
)}
</div>
</div>
)}
@@ -147,7 +229,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({parseFloat(searchParams.get('radius') || '1')} mi)</span>}
</div>
)}
@@ -167,13 +248,14 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<div className={styles.mapViewContainer}>
<div className={styles.mapContainer}>
<SchoolMap
schools={initialSchools.schools}
schools={isLoadingMap ? initialSchools.schools : mapSchools}
center={initialSchools.location_info?.coordinates}
referencePoint={initialSchools.location_info?.coordinates}
onMarkerClick={setSelectedMapSchool}
/>
</div>
<div className={styles.compactList}>
{initialSchools.schools.map((school) => (
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
<div
key={school.urn}
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
@@ -206,6 +288,17 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<>
<div className={styles.schoolList}>
{sortedSchools.map((school) => (
school.attainment_8_score != null ? (
<SecondarySchoolRow
key={school.urn}
school={school}
isLocationSearch={isLocationSearch}
onAddToCompare={addSchool}
onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
/>
) : (
<SchoolRow
key={school.urn}
school={school}
@@ -214,15 +307,25 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
/>
)
))}
</div>
{initialSchools.total_pages > 1 && (
<Pagination
currentPage={initialSchools.page}
totalPages={initialSchools.total_pages}
total={initialSchools.total}
/>
{(hasMore || allSchools.length < initialSchools.total) && (
<div className={styles.loadMoreSection}>
<p className={styles.loadMoreCount}>
Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
</p>
{hasMore && (
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className={`btn btn-secondary ${styles.loadMoreButton}`}
>
{isLoadingMore ? 'Loading...' : 'Load more schools'}
</button>
)}
</div>
)}
</>
)}
@@ -243,7 +346,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
<div className={styles.compactItem}>
<div className={styles.compactItemContent}>
<div className={styles.compactItemHeader}>
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
{school.school_name}
</a>
{school.distance !== undefined && school.distance !== null && (
@@ -258,7 +361,14 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
</div>
<div className={styles.compactItemStats}>
<span className={styles.compactStat}>
<strong>{school.rwm_expected_pct !== null ? `${school.rwm_expected_pct}%` : '-'}</strong> RWM
<strong>
{school.attainment_8_score != null
? school.attainment_8_score.toFixed(1)
: school.rwm_expected_pct !== null
? `${school.rwm_expected_pct}%`
: '-'}
</strong>{' '}
{school.attainment_8_score != null ? 'Att 8' : 'RWM'}
</span>
<span className={styles.compactStat}>
<strong>{school.total_pupils || '-'}</strong> pupils
@@ -272,7 +382,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
>
{isInCompare ? '✓ Comparing' : '+ Compare'}
</button>
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
View
</a>
</div>

View File

@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
// Fix for default marker icons in Next.js
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -22,12 +23,14 @@ interface LeafletMapInnerProps {
schools: School[];
center: [number, number];
zoom: number;
referencePoint?: [number, number];
onMarkerClick?: (school: School) => void;
}
export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }: LeafletMapInnerProps) {
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick }: LeafletMapInnerProps) {
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const refMarkerRef = useRef<L.Marker | null>(null);
useEffect(() => {
if (!mapContainerRef.current) return;
@@ -42,13 +45,36 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
}).addTo(mapRef.current);
}
// Clear existing markers
// Clear existing school markers (not the reference pin)
mapRef.current.eachLayer((layer) => {
if (layer instanceof L.Marker) {
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
mapRef.current!.removeLayer(layer);
}
});
// Add reference pin (search location)
if (refMarkerRef.current) {
refMarkerRef.current.remove();
refMarkerRef.current = null;
}
if (referencePoint && mapRef.current) {
const refIcon = L.divIcon({
html: `<div style="
width: 20px; height: 20px;
background: #e07256;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: '',
});
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
.addTo(mapRef.current)
.bindPopup('<strong>Search location</strong>');
}
// Add markers for schools
schools.forEach((school) => {
if (school.latitude && school.longitude && mapRef.current) {
@@ -60,7 +86,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
</div>
`;
@@ -88,7 +114,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
return () => {
// Don't destroy map on every update, just clean markers
};
}, [schools, center, zoom, onMarkerClick]);
}, [schools, center, zoom, referencePoint, onMarkerClick]);
// Cleanup map on unmount
useEffect(() => {

View File

@@ -0,0 +1,83 @@
.wrapper {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 0.3em;
}
.icon {
font-size: 0.85em;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
.wrapper:hover .icon {
color: var(--accent-coral, #e07256);
}
.tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: 220px;
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
pointer-events: none;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
/* Keep tooltip visible when hovering over it */
.wrapper:hover .tooltip {
visibility: visible;
opacity: 1;
}
/* Small arrow pointing down */
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border-color, #e8ddd4);
}
.tooltipLabel {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.tooltipPlain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.tooltipDetail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
/* Flip tooltip below when near top of screen */
@media (max-width: 480px) {
.tooltip {
width: 180px;
}
}

View File

@@ -0,0 +1,31 @@
'use client';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import styles from './MetricTooltip.module.css';
interface MetricTooltipProps {
metricKey?: string;
label?: string;
plain?: string;
detail?: string;
}
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const tooltipLabel = label ?? explanation?.label;
const tooltipPlain = plain ?? explanation?.plain;
const tooltipDetail = detail ?? explanation?.detail;
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">
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
</span>
</span>
);
}

View File

@@ -18,6 +18,7 @@ import {
ChartOptions,
} from 'chart.js';
import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import styles from './PerformanceChart.module.css';
// Register Chart.js components
@@ -34,24 +35,50 @@ ChartJS.register(
interface PerformanceChartProps {
data: SchoolResult[];
schoolName: string;
isSecondary?: boolean;
}
export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
export function PerformanceChart({ data, schoolName, isSecondary = false }: PerformanceChartProps) {
// Sort data by year
const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => d.year.toString());
const years = sortedData.map(d => formatAcademicYear(d.year));
// Prepare datasets
const datasets = [
// Prepare datasets — phase-aware
const datasets = isSecondary ? [
{
label: 'RWM Expected %',
label: 'Attainment 8',
data: sortedData.map(d => d.attainment_8_score),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
yAxisID: 'y',
},
{
label: 'English & Maths Grade 4+',
data: sortedData.map(d => d.english_maths_standard_pass_pct),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
yAxisID: 'y',
},
{
label: 'Progress 8',
data: sortedData.map(d => d.progress_8_score),
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
yAxisID: 'y1',
},
] : [
{
label: 'Reading, Writing & Maths Expected %',
data: sortedData.map(d => d.rwm_expected_pct),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
},
{
label: 'RWM Higher %',
label: 'Reading, Writing & Maths Higher %',
data: sortedData.map(d => d.rwm_high_pct),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
@@ -153,14 +180,14 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
position: 'left' as const,
title: {
display: true,
text: 'Percentage (%)',
text: isSecondary ? 'Score / Percentage (%)' : 'Percentage (%)',
font: {
size: 12,
weight: 'bold',
},
},
min: 0,
max: 100,
max: isSecondary ? undefined : 100,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
@@ -171,7 +198,7 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
position: 'right' as const,
title: {
display: true,
text: 'Progress Score',
text: isSecondary ? 'Progress 8 Score' : 'Progress Score',
font: {
size: 12,
weight: 'bold',

View File

@@ -22,6 +22,47 @@
line-height: 1.6;
}
/* Phase Tabs */
.phaseTabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.phaseTab {
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.phaseTab:not(:last-child) {
border-right: 1px solid var(--border-color, #e5dfd5);
}
.phaseTab:hover {
background: var(--bg-secondary, #f3ede4);
}
.phaseTabActive {
background: var(--accent-coral, #e07256);
color: white;
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral, #e07256);
}
/* Filters */
.filters {
background: var(--bg-card, white);

View File

@@ -1,6 +1,6 @@
/**
* RankingsView Component
* Client-side rankings interface with filters
* Client-side rankings interface with phase tabs and filters
*/
'use client';
@@ -8,10 +8,29 @@
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.module.css';
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
const SECONDARY_CATEGORIES = ['gcse'];
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
interface RankingsViewProps {
rankings: RankingEntry[];
filters: Filters;
@@ -19,6 +38,7 @@ interface RankingsViewProps {
selectedMetric: string;
selectedArea?: string;
selectedYear?: number;
selectedPhase?: string;
}
export function RankingsView({
@@ -28,12 +48,17 @@ export function RankingsView({
selectedMetric,
selectedArea,
selectedYear,
selectedPhase = 'primary',
}: RankingsViewProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { addSchool, isSelected } = useComparison();
const isPrimary = selectedPhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const updateFilters = (updates: Record<string, string | undefined>) => {
const params = new URLSearchParams(searchParams);
@@ -48,6 +73,11 @@ export function RankingsView({
router.push(`${pathname}?${params.toString()}`);
};
const handlePhaseChange = (phase: string) => {
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
updateFilters({ phase, metric: defaultMetric });
};
const handleMetricChange = (metric: string) => {
updateFilters({ metric });
};
@@ -63,7 +93,6 @@ export function RankingsView({
const handleAddToCompare = (ranking: RankingEntry) => {
addSchool({
...ranking,
// Ensure required School fields are present
address: null,
postcode: null,
latitude: null,
@@ -77,6 +106,9 @@ export function RankingsView({
const isProgressScore = selectedMetric.includes('progress');
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
// Filter metrics to only show relevant categories
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
return (
<div className={styles.container}>
{/* Header */}
@@ -84,10 +116,26 @@ export function RankingsView({
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()}
{!selectedArea && <span className={styles.limitNote}> showing top {rankings.length}</span>}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p>
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary (KS2)
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary (GCSE)
</button>
</div>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
@@ -107,46 +155,17 @@ export function RankingsView({
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.filterSelect}
>
<optgroup label="Expected Standard">
{metrics.filter(m => m.category === 'expected').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Higher Standard">
{metrics.filter(m => m.category === 'higher').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Progress Scores">
{metrics.filter(m => m.category === 'progress').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Average Scores">
{metrics.filter(m => m.category === 'average').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Gender Performance">
{metrics.filter(m => m.category === 'gender').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="Equity (Disadvantaged)">
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="School Context">
{metrics.filter(m => m.category === 'context').map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
<optgroup label="3-Year Trends">
{metrics.filter(m => m.category === '3yr').map((metric) => (
{optgroups.map(({ label, category }) => {
const groupMetrics = filteredMetrics.filter(m => m.category === category);
if (groupMetrics.length === 0) return null;
return (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
</div>
@@ -180,11 +199,11 @@ export function RankingsView({
className={styles.filterSelect}
>
<option value="">
{filters.years.length > 0 ? `${Math.max(...filters.years)} (Latest)` : 'Latest'}
{filters.years.length > 0 ? `${formatAcademicYear(Math.max(...filters.years))} (Latest)` : 'Latest'}
</option>
{filters.years.map((year) => (
<option key={year} value={year}>
{year}
{formatAcademicYear(year)}
</option>
))}
</select>
@@ -199,7 +218,7 @@ export function RankingsView({
message="Try selecting a different metric, area, or year."
action={{
label: 'Clear filters',
onClick: () => router.push(pathname),
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
}}
/>
) : (
@@ -248,7 +267,7 @@ export function RankingsView({
)}
</td>
<td className={styles.schoolCell}>
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
{ranking.school_name}
</a>
</td>
@@ -258,7 +277,7 @@ export function RankingsView({
<strong>{displayValue}</strong>
</td>
<td className={styles.actionCell}>
<a href={`/school/${ranking.urn}`} className="btn btn-tertiary btn-sm">View</a>
<a href={schoolUrl(ranking.urn, ranking.school_name)} className="btn btn-tertiary btn-sm">View</a>
<button
onClick={() => handleAddToCompare(ranking)}
disabled={alreadyInComparison}

View File

@@ -5,7 +5,7 @@
import Link from 'next/link';
import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
import { formatPercentage, formatProgress, calculateTrend, getTrendColor, schoolUrl } from '@/lib/utils';
import styles from './SchoolCard.module.css';
interface SchoolCardProps {
@@ -25,7 +25,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
<div className={styles.header}>
<h3 className={styles.title}>
<Link href={`/school/${school.urn}`}>
<Link href={schoolUrl(school.urn, school.school_name)}>
{school.school_name}
</Link>
</h3>
@@ -48,12 +48,35 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
)}
</div>
{(school.rwm_expected_pct !== null || school.reading_progress !== null) && (
{(school.rwm_expected_pct != null || school.attainment_8_score != null || school.reading_progress !== null) && (
<div className={styles.metrics}>
{/* KS4 card metrics for secondary schools */}
{school.attainment_8_score != null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>
Attainment 8
<span className={styles.metricHint}>avg grade across best 8 GCSEs</span>
</span>
<div className={styles.metricValue}>
<strong>{school.attainment_8_score.toFixed(1)}</strong>
</div>
</div>
)}
{school.english_maths_standard_pass_pct != null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>
English &amp; Maths Grade 4+
<span className={styles.metricHint}>% standard pass in both</span>
</span>
<div className={styles.metricValue}>
<strong>{formatPercentage(school.english_maths_standard_pass_pct)}</strong>
</div>
</div>
)}
{school.rwm_expected_pct !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>
RWM Expected
Reading, Writing &amp; Maths
<span className={styles.metricHint}>% meeting expected standard</span>
</span>
<div className={styles.metricValue}>
@@ -123,7 +146,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
)}
<div className={styles.actions}>
<Link href={`/school/${school.urn}`} className="btn btn-primary">
<Link href={schoolUrl(school.urn, school.school_name)} className="btn btn-primary">
View Details
</Link>
{onAddToCompare && (

View File

@@ -5,17 +5,19 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { PerformanceChart } from './PerformanceChart';
import { SchoolMap } from './SchoolMap';
import { MetricTooltip } from './MetricTooltip';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import styles from './SchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
@@ -37,19 +39,6 @@ const RC_CATEGORIES = [
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
];
// 2023 national averages for context
const NATIONAL_AVG = {
rwm_expected: 60,
rwm_high: 8,
reading_expected: 73,
writing_expected: 71,
maths_expected: 73,
phonics_yr1: 79,
overall_absence: 6.7,
persistent_absence: 22,
class_size: 27,
per_pupil_spend: 6000,
};
function progressClass(val: number | null | undefined): string {
if (val == null) return '';
@@ -82,6 +71,23 @@ export function SchoolDetailView({
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection
const phase = schoolInfo.phase ?? '';
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
const isPrimary = !isSecondary;
// National averages (fetched dynamically so they stay current)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
useEffect(() => {
fetch('/api/national-averages')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setNationalAvg(data); })
.catch(() => {});
}, []);
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
@@ -108,13 +114,18 @@ export function SchoolDetailView({
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
// Determine whether this school has KS2 or KS4 results to show
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results;
// Build section nav items dynamically — only sections with data
const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
navItems.push({ id: 'parents', label: 'Parents' });
if (latestResults) navItems.push({ id: 'sats', label: 'SATs' });
if (hasPhonics) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
@@ -154,7 +165,7 @@ export function SchoolDetailView({
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a href={schoolInfo.website} target="_blank" rel="noopener noreferrer">
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
School website
</a>
</span>
@@ -328,33 +339,48 @@ export function SchoolDetailView({
</section>
)}
{/* SATs Results (merged with Subject Breakdown) */}
{latestResults && (
<section id="sats" className={styles.card}>
<h2 className={styles.sectionTitle}>SATs Results ({latestResults.year})</h2>
{/* Results Section (SATs for primary, GCSEs for secondary) */}
{hasAnyResults && latestResults && (
<section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}>
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.
{isSecondary
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
</p>
{/* Headline numbers: RWM combined */}
{/* ── Primary / KS2 content ── */}
{hasKS2Results && (
<>
<div className={styles.metricsGrid}>
{latestResults.rwm_expected_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Reading, Writing & Maths combined</div>
<div className={styles.metricLabel}>
Reading, Writing &amp; Maths combined
<MetricTooltip metricKey="rwm_expected_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_expected}%</div>
{primaryAvg.rwm_expected_pct != null && (
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.rwm_high_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Exceeding expected level (RWM)</div>
<div className={styles.metricLabel}>
Exceeding expected level (Reading, Writing &amp; Maths)
<MetricTooltip metricKey="rwm_high_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_high}%</div>
{primaryAvg.rwm_high_pct != null && (
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Per-subject detail table */}
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Reading</h3>
@@ -373,7 +399,10 @@ export function SchoolDetailView({
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="reading_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
{formatProgress(latestResults.reading_progress)}
</span>
@@ -381,7 +410,10 @@ export function SchoolDetailView({
)}
{latestResults.reading_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span>
<span className={styles.metricName}>
Average score
<MetricTooltip metricKey="reading_avg_score" />
</span>
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
</div>
)}
@@ -405,7 +437,10 @@ export function SchoolDetailView({
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="writing_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
{formatProgress(latestResults.writing_progress)}
</span>
@@ -431,7 +466,10 @@ export function SchoolDetailView({
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="maths_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
{formatProgress(latestResults.maths_progress)}
</span>
@@ -439,7 +477,10 @@ export function SchoolDetailView({
)}
{latestResults.maths_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span>
<span className={styles.metricName}>
Average score
<MetricTooltip metricKey="maths_avg_score" />
</span>
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
</div>
)}
@@ -452,13 +493,107 @@ export function SchoolDetailView({
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
</p>
)}
</>
)}
{/* ── Secondary / KS4 content ── */}
{hasKS4Results && (
<>
<div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Attainment 8
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Progress 8
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
<div className={styles.metricHint}>0 = national average</div>
</div>
)}
{latestResults.english_maths_standard_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* EBacc */}
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 4+
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 5+
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
</div>
</>
)}
</>
)}
</section>
)}
{/* Year 1 Phonics */}
{hasPhonics && phonics && (
{/* Year 1 Phonics — primary only */}
{hasPhonics && isPrimary && phonics && (
<section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({phonics.year})</h2>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}>
Phonics is a key early reading skill. Children are tested at the end of Year 1.
</p>
@@ -466,7 +601,7 @@ export function SchoolDetailView({
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Passed the phonics check</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.phonics_yr1}%</div>
<div className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
</div>
{phonics.year2_phonics_pct != null && (
<div className={styles.metricCard}>
@@ -487,21 +622,31 @@ export function SchoolDetailView({
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Average class size</div>
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.class_size} pupils</div>
<div className={styles.metricHint}>Average number of pupils per class</div>
</div>
)}
{absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Days missed (overall absence)</div>
<div className={styles.metricLabel}>
Days missed (overall absence)
<MetricTooltip metricKey="overall_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.overall_absence}%</div>
{primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)}
</div>
)}
{absenceData?.persistent_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Regularly missing school</div>
<div className={styles.metricLabel}>
Regularly missing school
<MetricTooltip metricKey="persistent_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.persistent_absence}%. Missing 10%+ of sessions.</div>
{primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
@@ -511,11 +656,18 @@ export function SchoolDetailView({
{/* How Hard to Get In */}
{admissions && (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({admissions.year})</h2>
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Oversubscribed'
: '✓ Not Oversubscribed'}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.published_admission_number != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 3 places per year</div>
<div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Year 3'} places per year</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div>
)}
@@ -525,20 +677,13 @@ export function SchoolDetailView({
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offers_pct != null && (
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first-choice</div>
<div className={styles.metricValue}>{admissions.first_preference_offers_pct}%</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ More applications than places last year'
: '✓ Places were available last year'}
</div>
)}
</section>
)}
@@ -556,13 +701,19 @@ export function SchoolDetailView({
)}
{latestResults?.eal_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>English as an additional language</div>
<div className={styles.metricLabel}>
English as an additional language
<MetricTooltip metricKey="eal_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
</div>
)}
{latestResults?.sen_support_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Pupils with additional needs (SEN support)</div>
<div className={styles.metricLabel}>
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
</div>
)}
@@ -610,7 +761,10 @@ export function SchoolDetailView({
{/* Local Area Context */}
{hasDeprivation && deprivation && (
<section id="local-area" className={styles.card}>
<h2 className={styles.sectionTitle}>Local Area Context</h2>
<h2 className={styles.sectionTitle}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h2>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
@@ -631,7 +785,7 @@ export function SchoolDetailView({
{/* Finances */}
{hasFinance && finance && (
<section id="finances" className={styles.card}>
<h2 className={styles.sectionTitle}>School Finances ({finance.year})</h2>
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
@@ -639,7 +793,7 @@ export function SchoolDetailView({
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>National avg: ~£{NATIONAL_AVG.per_pupil_spend.toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
@@ -665,6 +819,7 @@ export function SchoolDetailView({
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
/>
</div>
{yearlyData.length > 1 && (
@@ -675,22 +830,44 @@ export function SchoolDetailView({
<thead>
<tr>
<th>Year</th>
<th>Reading, Writing & Maths (expected %)</th>
{isSecondary ? (
<>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
<th>English &amp; Maths Grade 5+</th>
</>
) : (
<>
<th>Reading, Writing &amp; Maths (expected %)</th>
<th>Exceeding expected (%)</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
</>
)}
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{result.year}</td>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isSecondary ? (
<>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
</>
) : (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
</>
)}
</tr>
))}
</tbody>

View File

@@ -4,6 +4,34 @@
position: relative;
}
.mapWrapper.fullscreen {
width: 100vw;
height: 100vh;
}
.fullscreenBtn {
position: absolute;
top: 0.625rem;
right: 0.625rem;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: white;
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 4px;
cursor: pointer;
color: #333;
transition: background 0.15s ease, color 0.15s ease;
}
.fullscreenBtn:hover {
background: #f4f4f4;
color: #000;
}
.mapLoading {
width: 100%;
height: 100%;

View File

@@ -6,6 +6,7 @@
'use client';
import dynamic from 'next/dynamic';
import { useRef, useState, useEffect, useCallback } from 'react';
import type { School } from '@/lib/types';
import styles from './SchoolMap.module.css';
@@ -24,32 +25,69 @@ interface SchoolMapProps {
schools: School[];
center?: [number, number];
zoom?: number;
referencePoint?: [number, number];
onMarkerClick?: (school: School) => void;
}
export function SchoolMap({ schools, center, zoom = 13, onMarkerClick }: SchoolMapProps) {
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Sync state with browser fullscreen events (e.g. Escape key)
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
wrapperRef.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
// Calculate center if not provided
const mapCenter: [number, number] = center || (() => {
if (schools.length === 0) return [51.5074, -0.1278]; // Default to London
if (schools.length === 0) return [51.5074, -0.1278];
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
return [schools[0].latitude, schools[0].longitude];
}
// Calculate average position
const validSchools = schools.filter(s => s.latitude && s.longitude);
if (validSchools.length === 0) return [51.5074, -0.1278];
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
return [avgLat, avgLng];
})();
return (
<div className={styles.mapWrapper}>
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
<button
className={styles.fullscreenBtn}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'View map fullscreen'}
>
{isFullscreen ? (
/* Compress icon */
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
</svg>
) : (
/* Expand icon */
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
</svg>
)}
</button>
<LeafletMap
schools={schools}
center={mapCenter}
zoom={zoom}
referencePoint={referencePoint}
onMarkerClick={onMarkerClick}
/>
</div>

View File

@@ -6,16 +6,20 @@
border: 1px solid var(--border-color, #e5dfd5);
border-left: 3px solid transparent;
border-radius: 8px;
padding: 0.75rem 1rem;
padding: 1rem 1.25rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
animation: rowFadeIn 0.3s ease-out both;
}
.row:hover {
border-left-color: var(--accent-coral, #e07256);
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
}
/* Phase border colours */
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
.rowInCompare {
border-left-color: var(--accent-teal, #2d7d7d);
background: var(--bg-secondary, #f3ede4);
@@ -32,10 +36,10 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.35rem;
}
/* Line 1: name + type */
/* Line 1: name + ofsted */
.line1 {
display: flex;
align-items: baseline;
@@ -59,15 +63,39 @@
color: var(--accent-coral, #e07256);
}
.schoolType {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
/* Phase label pill */
.phaseLabel {
display: inline-block;
padding: 0.0625rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
margin-right: 0.25rem;
}
/* Line 2: stats */
.phaseLabelPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.phaseLabelAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
.phaseLabelNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
/* Line 2: context tags */
.line2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
}
.line2 span:not(:last-child)::after {
content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
}
/* Line 3: stats */
.line3 {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -107,17 +135,17 @@
.trendDown { color: var(--accent-coral, #e07256); }
.trendStable { color: var(--text-muted, #8a847a); }
/* Line 3: location */
.line3 {
/* Line 4: location */
.line4 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 0;
gap: 0;
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
}
.line3 span:not(:last-child)::after {
.line4 span:not(:last-child)::after {
content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
@@ -162,6 +190,10 @@
line-height: 1.4;
}
.ofstedDate {
font-weight: 400;
}
.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; }
@@ -171,7 +203,7 @@
@media (max-width: 640px) {
.row {
flex-wrap: wrap;
padding: 0.75rem;
padding: 0.875rem;
gap: 0.625rem;
}
@@ -183,7 +215,7 @@
white-space: normal;
}
.line2 {
.line3 {
gap: 0 1rem;
}

View File

@@ -1,14 +1,15 @@
/**
* SchoolRow Component
* Three-line row for school search results
* Four-line row for primary school search results
*
* Line 1: School name · School type
* Line 2: R,W&M % · Progress score · Pupil count
* Line 3: Local authority · Distance
* Line 1: School name · Ofsted badge
* Line 2: School type · Age range · Denomination · Gender
* Line 3: R,W&M % · Progress score · Pupil count
* Line 4: Local authority · Distance
*/
import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
import { formatPercentage, formatProgress, calculateTrend, getPhaseStyle, schoolUrl } from '@/lib/utils';
import { progressBand } from '@/lib/metrics';
import styles from './SchoolRow.module.css';
@@ -35,6 +36,7 @@ export function SchoolRow({
onRemoveFromCompare,
}: SchoolRowProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const phase = getPhaseStyle(school.phase);
// Use reading progress as representative; fall back to writing, then maths
const progressScore =
@@ -48,28 +50,48 @@ export function SchoolRow({
}
};
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination =
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
return (
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: three content lines */}
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: four content lines */}
<div className={styles.rowContent}>
{/* Line 1: School name + type + Ofsted badge */}
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}>
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name}
</a>
{school.school_type && (
<span className={styles.schoolType}>{school.school_type}</span>
)}
{school.ofsted_grade && (
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
{OFSTED_LABELS[school.ofsted_grade]}
{school.ofsted_date && (
<span className={styles.ofstedDate}>
{' '}({new Date(school.ofsted_date).getFullYear()})
</span>
)}
</span>
)}
</div>
{/* Line 2: Key stats */}
{/* Line 2: Context tags */}
<div className={styles.line2}>
{phase.label && (
<span className={`${styles.phaseLabel} ${styles[`phaseLabel${phase.key}`]}`}>
{phase.label}
</span>
)}
{school.school_type && <span>{school.school_type}</span>}
{school.age_range && <span>{school.age_range}</span>}
{showDenomination && <span>{school.religious_denomination}</span>}
{showGender && <span>{school.gender}</span>}
</div>
{/* Line 3: Key stats */}
<div className={styles.line3}>
{school.rwm_expected_pct != null ? (
<span className={styles.stat}>
<strong className={styles.statValue}>
@@ -123,8 +145,8 @@ export function SchoolRow({
)}
</div>
{/* Line 3: Location + distance */}
<div className={styles.line3}>
{/* Line 4: Location + distance */}
<div className={styles.line4}>
{school.local_authority && (
<span>{school.local_authority}</span>
)}
@@ -133,18 +155,13 @@ export function SchoolRow({
{school.distance.toFixed(1)} mi
</span>
)}
{!isLocationSearch &&
school.religious_denomination &&
school.religious_denomination !== 'Does not apply' && (
<span>{school.religious_denomination}</span>
)}
</div>
</div>
{/* Right: actions, vertically centred */}
<div className={styles.rowActions}>
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
View
</a>
{(onAddToCompare || onRemoveFromCompare) && (

View File

@@ -0,0 +1,773 @@
/* SecondarySchoolDetailView — borrows heavily from SchoolDetailView.module.css */
.container {
width: 100%;
}
/* ── Header ──────────────────────────────────────────── */
.header {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 0;
box-shadow: var(--shadow-soft);
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
}
.titleSection {
flex: 1;
}
.schoolName {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem;
line-height: 1.2;
font-family: var(--font-playfair), 'Playfair Display', serif;
overflow-wrap: break-word;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.badge {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 3px;
}
.badgeSelective {
background: rgba(180, 120, 0, 0.1);
color: #8a6200;
}
.badgeFaith {
background: rgba(45, 125, 125, 0.1);
color: var(--accent-teal, #2d7d7d);
}
.address {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
margin: 0 0 0.75rem;
overflow-wrap: break-word;
}
.headerDetails {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.5rem;
}
.headerDetail {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
}
.headerDetail strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
.headerDetail a {
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
}
.headerDetail a:hover {
text-decoration: underline;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btnAdd,
.btnRemove {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.btnAdd {
background: var(--accent-coral, #e07256);
color: white;
}
.btnAdd:hover {
background: var(--accent-coral-dark, #c45a3f);
transform: translateY(-1px);
}
.btnRemove {
background: var(--accent-teal, #2d7d7d);
color: white;
}
.btnRemove:hover {
opacity: 0.9;
}
/* ── Tab Navigation (sticky) ─────────────────────────── */
.tabNav {
position: sticky;
top: 3.5rem;
z-index: 10;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-top: none;
border-radius: 0 0 10px 10px;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.tabNav::-webkit-scrollbar {
display: none;
}
.tabNavInner {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.backBtn {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral, #e07256);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
margin-right: 0.25rem;
}
.backBtn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
}
.tabNavDivider {
width: 1px;
height: 1rem;
background: var(--border-color, #e5dfd5);
margin: 0 0.25rem;
flex-shrink: 0;
}
.tabBtn {
display: inline-block;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none;
}
.tabBtn:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
/* ── Card ────────────────────────────────────────────── */
.card {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
scroll-margin-top: 6rem;
}
/* ── Section Title ───────────────────────────────────── */
.sectionTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
margin-bottom: 0.875rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color, #e5dfd5);
font-family: var(--font-playfair), 'Playfair Display', serif;
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
overflow-wrap: break-word;
min-width: 0;
}
.sectionTitle::before {
content: '';
display: inline-block;
width: 3px;
height: 1em;
background: var(--accent-coral, #e07256);
border-radius: 2px;
flex-shrink: 0;
}
.sectionSubtitle {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
margin: -0.5rem 0 1rem;
}
.subSectionTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #5c564d);
margin: 1.25rem 0 0.75rem;
}
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
/* ── Progress 8 suspension banner ───────────────────── */
.p8Banner {
background: rgba(180, 120, 0, 0.1);
border: 1px solid rgba(180, 120, 0, 0.3);
color: #8a6200;
border-radius: 6px;
padding: 0.625rem 0.875rem;
font-size: 0.825rem;
margin-bottom: 1rem;
line-height: 1.5;
}
/* ── Metrics Grid & Cards ────────────────────────────── */
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.metricCard {
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
padding: 0.75rem;
text-align: center;
}
.metricLabel {
font-size: 0.6875rem;
color: var(--text-muted, #8a847a);
margin-bottom: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metricValue {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
overflow-wrap: break-word;
word-break: break-word;
}
.metricHint {
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
margin-top: 0.3rem;
font-style: italic;
}
/* ── Progress score colours ──────────────────────────── */
.progressPositive {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.progressNegative {
color: var(--accent-coral, #e07256);
font-weight: 700;
}
/* ── Status colours ──────────────────────────────────── */
.statusGood {
background: var(--accent-teal-bg);
color: var(--accent-teal, #2d7d7d);
}
.statusWarn {
background: var(--accent-gold-bg);
color: #b8920e;
}
/* ── Metric table (row-based) ────────────────────────── */
.metricTable {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.metricRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.625rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
}
.metricName {
font-size: 0.75rem;
color: var(--text-secondary, #5c564d);
}
.metricRow .metricValue {
font-size: 0.875rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
}
/* ── Charts & Map ────────────────────────────────────── */
.chartContainer {
width: 100%;
height: 280px;
position: relative;
}
.mapContainer {
width: 100%;
height: 300px;
border-radius: 6px;
overflow: hidden;
}
/* ── History table ───────────────────────────────────── */
.tableWrapper {
overflow-x: auto;
margin-top: 0.5rem;
}
.historicalSubtitle {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
margin: 1.25rem 0 0.25rem;
}
.dataTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.dataTable thead {
background: var(--bg-secondary, #f3ede4);
}
.dataTable th {
padding: 0.625rem 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5);
}
.dataTable td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
color: var(--text-secondary, #5c564d);
}
.dataTable tbody tr:last-child td {
border-bottom: none;
}
.dataTable tbody tr:hover {
background: var(--bg-secondary, #f3ede4);
}
.yearCell {
font-weight: 600;
color: var(--accent-gold, #c9a227);
}
/* ── Ofsted ──────────────────────────────────────────── */
.ofstedHeader {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.ofstedGrade {
display: inline-block;
padding: 0.3rem 0.75rem;
font-size: 1rem;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
.safeguardingMet {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
background: var(--accent-teal-bg);
color: var(--accent-teal, #2d7d7d);
}
.safeguardingNotMet {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 700;
background: var(--accent-coral-bg);
color: var(--accent-coral, #e07256);
}
.ofstedDisclaimer {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
font-style: italic;
margin: 0 0 1rem;
}
.ofstedDate {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
}
.ofstedPrevious {
font-size: 0.8125rem;
color: var(--text-muted, #8a847a);
font-style: italic;
}
.ofstedReportLink {
font-size: 0.8125rem;
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
margin-left: auto;
white-space: nowrap;
}
.ofstedReportLink:hover {
text-decoration: underline;
}
/* ── Parent View ─────────────────────────────────────── */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* ── Admissions ──────────────────────────────────────── */
.admissionsTypeBadge {
border-radius: 6px;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.admissionsSelective {
background: rgba(180, 120, 0, 0.1);
color: #8a6200;
border: 1px solid rgba(180, 120, 0, 0.25);
}
.admissionsFaith {
background: rgba(45, 125, 125, 0.08);
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.2);
}
.admissionsBadge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
margin-top: 0.75rem;
}
.sixthFormNote {
margin-top: 1rem;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 6px;
font-size: 0.825rem;
color: var(--text-secondary, #5c564d);
border-left: 3px solid var(--accent-teal, #2d7d7d);
}
/* ── Deprivation ─────────────────────────────────────── */
.deprivationDots {
display: flex;
gap: 0.375rem;
margin: 0.75rem 0 0.5rem;
align-items: center;
}
.deprivationDot {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--bg-secondary, #f3ede4);
border: 2px solid var(--border-color, #e5dfd5);
flex-shrink: 0;
}
.deprivationDotFilled {
background: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
}
.deprivationDesc {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
.deprivationScaleLabel {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
margin-top: 0.25rem;
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 768px) {
.header {
padding: 1rem;
}
.headerContent {
flex-direction: column;
gap: 1rem;
}
.actions {
width: 100%;
}
.btnAdd,
.btnRemove {
flex: 1;
}
.schoolName {
font-size: 1.25rem;
word-break: break-word;
}
.badges {
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.1rem 0.375rem;
}
.headerDetails {
flex-direction: column;
gap: 0.375rem;
}
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
.metricValue {
font-size: 1rem;
}
.chartContainer {
height: 220px;
}
.dataTable {
font-size: 0.75rem;
}
.dataTable th,
.dataTable td {
padding: 0.5rem 0.375rem;
}
.card {
padding: 1rem;
}
.sectionTitle {
font-size: 1rem;
}
.parentViewLabel {
flex-basis: 10rem;
}
.ofstedReportLink {
margin-left: 0;
display: block;
margin-top: 0.25rem;
}
.admissionsTypeBadge {
font-size: 0.75rem;
}
}
@media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.metricsGrid {
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.metricCard {
padding: 0.5rem;
}
.metricLabel {
font-size: 0.625rem;
}
.card {
padding: 0.75rem;
}
}

View File

@@ -0,0 +1,709 @@
/**
* SecondarySchoolDetailView Component
* Dedicated detail view for secondary schools with scroll-to-section navigation.
* All sections render at once; the sticky nav scrolls to each.
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { PerformanceChart } from './PerformanceChart';
import { MetricTooltip } from './MetricTooltip';
import { SchoolMap } from './SchoolMap';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import styles from './SecondarySchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
const RC_LABELS: Record<number, string> = {
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
};
const RC_CATEGORIES = [
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
{ key: 'rc_achievement' as const, label: 'Achievement' },
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
{ key: 'rc_early_years' as const, label: 'Early Years' },
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
];
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
if (val == null) return '';
if (val > 0) return modStyles.progressPositive;
if (val < 0) return modStyles.progressNegative;
return '';
}
function deprivationDesc(decile: number): string {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
interface SecondarySchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
useEffect(() => {
fetch('/api/national-averages')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setNationalAvg(data); })
.catch(() => {});
}, []);
const secondaryAvg = nationalAvg?.secondary ?? {};
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null;
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
})();
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
} else {
addSchool(schoolInfo);
}
};
// Build nav items dynamically based on available data
const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasLocation) navItems.push({ id: 'location', label: 'Location' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
return (
<div className={styles.container}>
{/* ── Header ─────────────────────────────────────── */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.badges}>
{schoolInfo.school_type && (
<span className={styles.badge}>{schoolInfo.school_type}</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.badge}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.badge}>{schoolInfo.age_range}</span>
)}
{hasSixthForm && (
<span className={styles.badge}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
{admissionsTag}
</span>
)}
</div>
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
</p>
)}
<div className={styles.headerDetails}>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
School website
</a>
</span>
)}
{latestResults?.total_pupils != null && (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {latestResults.total_pupils.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
)}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
</div>
</div>
<div className={styles.actions}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</button>
</div>
</div>
</header>
{/* ── Sticky section navigation ─────────────────────── */}
<nav className={styles.tabNav} aria-label="Page sections">
<div className={styles.tabNavInner}>
<button onClick={() => router.back()} className={styles.backBtn}> Back</button>
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
{navItems.map(({ id, label }) => (
<a key={id} href={`#${id}`} className={styles.tabBtn}>{label}</a>
))}
</div>
</nav>
{/* ── Ofsted ─────────────────────────────────────── */}
{ofsted && (
<section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}>
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofsted.inspection_date && (
<span className={styles.ofstedDate}>
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
href={`https://reports.ofsted.gov.uk/provider/21/${schoolInfo.urn}`}
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
>
Full report
</a>
</h2>
{ofsted.framework === 'ReportCard' ? (
<>
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</div>
</div>
)}
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
const value = ofsted[key] as number | null;
return value != null ? (
<div key={key} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
{RC_LABELS[value]}
</div>
</div>
) : null;
})}
</div>
</>
) : ofsted.overall_effectiveness ? (
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{OFSTED_LABELS[ofsted.overall_effectiveness]}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
</p>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
{ label: 'School Leadership', value: ofsted.leadership_management },
...(ofsted.early_years_provision != null
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
: []),
].map(({ label, value }) => value != null && (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
</>
) : (
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
</div>
))}
</div>
</>
)}
{hasParents && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
</p>
)}
</section>
)}
{/* ── Parent View ────────────────────────────────── */}
{hasParents && parentView && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses!.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* ── GCSE Results ───────────────────────────────── */}
{hasResults && latestResults && (
<section id="gcse" className={styles.card}>
<h2 className={styles.sectionTitle}>
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils. National averages shown for comparison.
</p>
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
</div>
)}
<div className={styles.metricsGrid}>
{latestResults.attainment_8_score != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Attainment 8
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Progress 8
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
{(latestResults.progress_8_lower_ci != null || latestResults.progress_8_upper_ci != null) && (
<div className={styles.metricHint}>
CI: {latestResults.progress_8_lower_ci?.toFixed(2) ?? '?'} to {latestResults.progress_8_upper_ci?.toFixed(2) ?? '?'}
</div>
)}
</div>
)}
{latestResults.english_maths_standard_pass_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Progress 8 component breakdown */}
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
<>
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
<div className={styles.metricTable}>
{[
{ label: 'English', val: latestResults.progress_8_english },
{ label: 'Maths', val: latestResults.progress_8_maths },
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
].filter(r => r.val != null).map(({ label, val }) => (
<div key={label} className={styles.metricRow}>
<span className={styles.metricName}>{label}</span>
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
{formatProgress(val!)}
</span>
</div>
))}
</div>
</>
)}
{/* EBacc */}
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 4+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 5+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_avg_score != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc average point score</span>
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
</div>
)}
</div>
</>
)}
{/* Performance chart */}
{yearlyData.length > 0 && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
/>
</div>
</>
)}
</section>
)}
{/* ── Admissions ─────────────────────────────────── */}
{admissions && (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>Admissions</h2>
{admissionsTag && (
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
<strong>{admissionsTag}</strong>{' '}
{admissionsTag === 'Selective'
? '— Entry to this school is by selective examination (e.g. 11+).'
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.published_admission_number != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places per year (PAN)</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
: '✓ Places were available last year'}
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
</div>
)}
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && (
<section id="wellbeing" className={styles.card}>
<h2 className={styles.sectionTitle}>Wellbeing &amp; Context</h2>
{/* SEN */}
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
<>
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
<div className={styles.metricsGrid}>
{latestResults?.sen_support_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.metricHint}>SEN support without an EHCP</div>
</div>
)}
{latestResults?.sen_ehcp_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Pupils with an EHCP
<MetricTooltip metricKey="sen_ehcp_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
<div className={styles.metricHint}>Education, Health and Care Plan</div>
</div>
)}
{latestResults?.total_pupils != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total pupils</div>
<div className={styles.metricValue}>{latestResults.total_pupils.toLocaleString()}</div>
{schoolInfo.capacity != null && (
<div className={styles.metricHint}>Capacity: {schoolInfo.capacity}</div>
)}
</div>
)}
</div>
</>
)}
{/* Deprivation */}
{hasDeprivation && deprivation && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h3>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</>
)}
</section>
)}
{/* ── Location ───────────────────────────────────── */}
{hasLocation && (
<section id="location" className={styles.card}>
<h2 className={styles.sectionTitle}>Location</h2>
<div className={styles.mapContainer}>
<SchoolMap
schools={[schoolInfo]}
center={[schoolInfo.latitude!, schoolInfo.longitude!]}
zoom={15}
/>
</div>
</section>
)}
{/* ── Finances ───────────────────────────────────── */}
{hasFinance && finance && (
<section id="finances" className={styles.card}>
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.premises_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on premises</div>
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</section>
)}
{/* ── History table ──────────────────────────────── */}
{yearlyData.length > 1 && (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Historical Results</h2>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>Eng &amp; Maths 4+</th>
<th>EBacc entry %</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More