Compare commits

...

36 Commits

Author SHA1 Message Date
Tudor
75677f4252 Add contact form to footer and simplify footer content
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
Replace footer note with a contact form that emails contact@schoolcompare.co.uk
via FormSubmit.co. Keep only the data source attribution. Update CSP to allow
form submissions to FormSubmit.co and add responsive styling for the form.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:01:47 +00:00
Tudor
9b6c37cda3 Improve school modal charts for mobile visualization
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
- Make charts taller on mobile with better aspect ratios (0.9 for <480px, 1.1 for <768px)
- Shorten chart title and dataset labels on mobile
- Add responsive font sizes for legend, title, and axis ticks
- Add mobile-specific styling for chart container, stats grid, and modal
- Add extra-small screen breakpoint (480px) for very narrow devices

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:09:07 +00:00
Tudor
f2eec08bd4 Add Portainer webhook trigger to CI workflow
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
Automatically redeploy the stack when new images are pushed to the registry.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:16:29 +00:00
Tudor
f7b9a4d28e bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-13 19:51:21 +00:00
Tudor
c23e12fc12 fixed add to compare button position
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-13 15:55:32 +00:00
Tudor
a8fe4477f1 Added warning about lack of progress results, moved add to compare button
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
2026-01-13 15:12:11 +00:00
Tudor
1a9341eaf4 Simplify school types and persist selected schools
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
- Add runtime normalization of cryptic school type codes to user-friendly names
  (e.g., AC/ACC/ACCS -> "Academy", CY/CYS -> "Community")
- Update SCHOOL_TYPE_MAP in schemas.py with consolidated mappings
- Add normalize_school_type() and get_school_type_codes_for_filter() helpers
- Persist selected schools in localStorage across page refreshes
- Move "Add to Compare" button from modal footer to header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:55:23 +00:00
Tudor
708fbe83a0 fixing GA implementation race condition for account id retrieva
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-12 09:35:53 +00:00
Tudor
8e4802df93 fixing GA implementation race condition for account id retrieval
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-12 09:18:40 +00:00
Tudor
a18ec04227 fixing GA implementation race condition for account id retrieval
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-12 09:10:36 +00:00
Tudor
9cd36a0b15 Add Google Analytics 4 with cookie consent integration
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
- Add GA4 measurement ID to config (default: G-J0PCVT14NY)
- Add /api/config endpoint to expose GA ID to frontend
- Update cookie consent with Analytics category (opt-in)
- Load GA4 only after user consents to analytics cookies
- Update CSP to allow Google Analytics domains

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:19:05 +00:00
Tudor
1f6b2dd773 align school cards
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
2026-01-10 13:17:30 +00:00
Tudor
6597ee40fb bug fixing
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-10 11:40:02 +00:00
Tudor
bb58d607c2 bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-10 11:32:40 +00:00
Tudor
e1383b3432 Fix postcode search ValueError when calculating distances
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
Use direct bracket indexing instead of .get() for pandas Series
row access in calc_distance function to ensure scalar values
are returned for pd.isna() checks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:25:21 +00:00
Tudor
3c1e7b4b27 removing some stats
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-09 16:55:20 +00:00
Tudor
597a841d4d fixing appearance of stats on cards
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-09 15:58:36 +00:00
Tudor
ab45f66431 adding half mile search option
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-09 15:12:11 +00:00
Tudor
c63e0e2682 introducing tooltips
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-09 15:10:39 +00:00
Tudor
79cf16d6b3 Add higher standard display and trend indicators to school cards
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
- Display RWM Higher % alongside RWM Expected % on school cards
- Add trend indicators (up/down/stable arrows) showing year-over-year change
- Backend calculates previous year's RWM for trend comparison
- Trend appears on cards and in school detail modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:36:01 +00:00
Tudor
e3fc031ecf addings details and map to modal
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-09 11:52:13 +00:00
Tudor
058a741b10 fixed map overlapping modals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-09 08:52:51 +00:00
Tudor
ea3f65249e fixed map overlapping modals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-09 08:42:38 +00:00
Tudor
b0e2a42acc fixed map overlapping modals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-09 08:36:33 +00:00
Tudor
1e6019eac3 fixed map overlapping modals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-09 08:25:19 +00:00
Tudor
3f118ef826 fixed map overlapping modals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-09 08:13:19 +00:00
Tudor
8458d638ec bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-01-09 00:10:40 +00:00
Tudor
51836852e4 bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-09 00:07:51 +00:00
Tudor
116be294a3 bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-08 23:55:36 +00:00
Tudor
4b91eb403a bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-08 23:53:30 +00:00
Tudor
6623418dbe bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-08 23:48:56 +00:00
Tudor
3f8e1911aa bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-08 23:29:48 +00:00
Tudor
b7943e1042 implementing map on school card; adding more school details
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-08 23:20:42 +00:00
Tudor
34f40c0c1c Renaming dashboard to home
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-08 22:59:55 +00:00
Tudor
1d19c88e49 bug fix
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-08 16:18:11 +00:00
Tudor
40348cb1bd moving geocoding to a background task
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-08 15:30:33 +00:00
11 changed files with 1784 additions and 202 deletions

View File

@@ -50,3 +50,8 @@ jobs:
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
- name: Trigger Portainer stack update
if: gitea.event_name != 'pull_request'
run: |
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"

1
.gitignore vendored
View File

@@ -1 +1,2 @@
venv
backend/__pycache__

View File

@@ -179,6 +179,31 @@ Data is sourced from the UK Government's [Compare School Performance](https://ww
**Important**: When using real data, please comply with the [terms of use](https://www.compare-school-performance.service.gov.uk/download-data) and data protection regulations.
## Scheduled Jobs
### Geocoding Schools (Cron Job)
School postcodes are geocoded by a scheduled job, not on-demand. This improves performance and reduces API calls.
**Setup the cron job** (runs weekly on Sunday at 2am):
```bash
# Edit crontab
crontab -e
# Add this line (adjust paths as needed):
0 2 * * 0 cd /path/to/school_compare && /path/to/venv/bin/python scripts/geocode_schools.py >> /var/log/geocode_schools.log 2>&1
```
**Manual run:**
```bash
# Geocode only schools missing coordinates
python scripts/geocode_schools.py
# Force re-geocode all schools
python scripts/geocode_schools.py --force
```
## License
MIT License - feel free to use this project for educational purposes.

View File

@@ -8,6 +8,7 @@ import re
from contextlib import asynccontextmanager
from typing import Optional
import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
@@ -21,10 +22,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import settings
from .data_loader import (
clear_cache,
geocode_postcodes_bulk,
geocode_single_postcode,
haversine_distance,
load_school_data,
geocode_single_postcode,
)
from .data_loader import get_data_info as get_db_info
from .database import init_db
@@ -66,14 +65,14 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Content Security Policy
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://www.googletagmanager.com; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data:; "
"connect-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com; "
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
"form-action 'self' https://formsubmit.co;"
)
# HSTS (only enable if using HTTPS in production)
@@ -200,6 +199,14 @@ async def serve_rankings():
return FileResponse(settings.frontend_dir / "index.html")
@app.get("/api/config")
async def get_config():
"""Return public configuration for the frontend."""
return {
"ga_measurement_id": settings.ga_measurement_id
}
@app.get("/api/schools")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_schools(
@@ -239,11 +246,23 @@ async def get_schools(
latest_year = df.groupby("urn")["year"].max().reset_index()
df_latest = df.merge(latest_year, on=["urn", "year"])
# Calculate trend by comparing to previous year
# Get second-latest year for each school
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
)
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
# Include key result metrics for display on cards
location_cols = ["latitude", "longitude"]
result_cols = [
"year",
"rwm_expected_pct",
"rwm_high_pct",
"prev_rwm_expected_pct",
"reading_expected_pct",
"writing_expected_pct",
"maths_expected_pct",
@@ -256,7 +275,7 @@ async def get_schools(
]
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
# Location-based search
# Location-based search (uses pre-geocoded data from database)
search_coords = None
if postcode:
coords = geocode_single_postcode(postcode)
@@ -264,35 +283,36 @@ async def get_schools(
search_coords = coords
schools_df = schools_df.copy()
# Geocode school postcodes on-demand if not already cached
if "postcode" in schools_df.columns:
unique_postcodes = schools_df["postcode"].dropna().unique().tolist()
geocoded = geocode_postcodes_bulk(unique_postcodes)
# Filter by distance using pre-geocoded lat/long from database
# Use vectorized haversine calculation for better performance
lat1, lon1 = search_coords
# Add lat/long from geocoded data
schools_df["latitude"] = schools_df["postcode"].apply(
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[0]
if pd.notna(pc)
else None
)
schools_df["longitude"] = schools_df["postcode"].apply(
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[1]
if pd.notna(pc)
else None
)
# Handle potential duplicate columns by taking first occurrence
lat_col = schools_df.loc[:, "latitude"]
lon_col = schools_df.loc[:, "longitude"]
if isinstance(lat_col, pd.DataFrame):
lat_col = lat_col.iloc[:, 0]
if isinstance(lon_col, pd.DataFrame):
lon_col = lon_col.iloc[:, 0]
# Filter by distance
def calc_distance(row):
if pd.isna(row.get("latitude")) or pd.isna(row.get("longitude")):
return float("inf")
return haversine_distance(
search_coords[0],
search_coords[1],
row["latitude"],
row["longitude"],
)
lat2 = lat_col.values
lon2 = lon_col.values
schools_df["distance"] = schools_df.apply(calc_distance, axis=1)
# Vectorized haversine formula
R = 3959 # Earth's radius in miles
lat1_rad = np.radians(lat1)
lat2_rad = np.radians(lat2)
dlat = np.radians(lat2 - lat1)
dlon = np.radians(lon2 - lon1)
a = np.sin(dlat / 2) ** 2 + np.cos(lat1_rad) * np.cos(lat2_rad) * np.sin(dlon / 2) ** 2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
distances = R * c
# Handle missing coordinates
has_coords = ~(pd.isna(lat_col) | pd.isna(lon_col))
distances = np.where(has_coords.values, distances, float("inf"))
schools_df["distance"] = distances
schools_df = schools_df[schools_df["distance"] <= radius]
schools_df = schools_df.sort_values("distance")
@@ -324,13 +344,8 @@ async def get_schools(
end_idx = start_idx + page_size
schools_df = schools_df.iloc[start_idx:end_idx]
# Remove internal columns before sending
output_cols = [c for c in schools_df.columns if c not in ["latitude", "longitude"]]
if "distance" in schools_df.columns:
output_cols.append("distance")
return {
"schools": clean_for_json(schools_df[output_cols]),
"schools": clean_for_json(schools_df),
"total": total,
"page": page,
"page_size": page_size,
@@ -372,6 +387,10 @@ async def get_school_details(request: Request, urn: int):
"local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""),
"address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": "Primary",
},
"yearly_data": clean_for_json(school_data),

View File

@@ -38,6 +38,9 @@ class Settings(BaseSettings):
rate_limit_burst: int = 10 # Allow burst of requests
max_request_size: int = 1024 * 1024 # 1MB max request size
# Analytics
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
class Config:
env_file = ".env"
env_file_encoding = "utf-8"

View File

@@ -1,6 +1,9 @@
"""
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().
"""
import pandas as pd
@@ -14,57 +17,37 @@ from sqlalchemy.orm import joinedload, Session
from .config import settings
from .database import SessionLocal, get_db_session
from .models import School, SchoolResult
from .schemas import SCHOOL_TYPE_MAP
# Cache for postcode geocoding
# Cache for user search postcode geocoding (not for school data)
_postcode_cache: Dict[str, Tuple[float, float]] = {}
def geocode_postcodes_bulk(postcodes: list) -> Dict[str, Tuple[float, float]]:
"""
Geocode postcodes in bulk using postcodes.io API.
Returns dict of postcode -> (latitude, longitude).
"""
results = {}
# Check cache first
uncached = []
for pc in postcodes:
if pc and isinstance(pc, str):
pc_upper = pc.strip().upper()
if pc_upper in _postcode_cache:
results[pc_upper] = _postcode_cache[pc_upper]
elif len(pc_upper) >= 5:
uncached.append(pc_upper)
if not uncached:
return results
uncached = list(set(uncached))
# postcodes.io allows max 100 postcodes per request
batch_size = 100
for i in range(0, len(uncached), batch_size):
batch = uncached[i:i + batch_size]
try:
response = requests.post(
'https://api.postcodes.io/postcodes',
json={'postcodes': batch},
timeout=30
)
if response.status_code == 200:
data = response.json()
for item in data.get('result', []):
if item and item.get('result'):
pc = item['query'].upper()
lat = item['result'].get('latitude')
lon = item['result'].get('longitude')
if lat and lon:
results[pc] = (lat, lon)
_postcode_cache[pc] = (lat, lon)
except Exception as e:
print(f" Warning: Geocoding batch failed: {e}")
return results
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]]:
@@ -160,18 +143,24 @@ def get_available_local_authorities(db: Session = None) -> List[str]:
def get_available_school_types(db: Session = None) -> List[str]:
"""Get list of available school types."""
"""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()\
.order_by(School.school_type)\
.all()
return [r[0] for r in result if r[0]]
# 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()
@@ -217,17 +206,20 @@ def get_schools(
if local_authority:
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
if school_type:
query = query.filter(func.lower(School.school_type) == school_type.lower())
# 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
@@ -265,10 +257,13 @@ def get_schools_near_location(
if local_authority:
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
if school_type:
query = query.filter(func.lower(School.school_type) == school_type.lower())
# 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()
@@ -380,17 +375,17 @@ def school_to_dict(school: School, include_results: bool = False) -> dict:
"urn": school.urn,
"school_name": school.school_name,
"local_authority": school.local_authority,
"school_type": school.school_type,
"school_type": normalize_school_type(school.school_type),
"address": school.address,
"town": school.town,
"postcode": school.postcode,
"latitude": school.latitude,
"longitude": school.longitude,
}
if include_results and school.results:
data["results"] = [result_to_dict(r) for r in school.results]
return data
@@ -455,11 +450,11 @@ def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
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()
rows = []
for school in schools:
for result in school.results:
@@ -467,7 +462,7 @@ def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
"urn": school.urn,
"school_name": school.school_name,
"local_authority": school.local_authority,
"school_type": school.school_type,
"school_type": normalize_school_type(school.school_type),
"address": school.address,
"town": school.town,
"postcode": school.postcode,
@@ -476,7 +471,7 @@ def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
**result_to_dict(result)
}
rows.append(row)
if rows:
return pd.DataFrame(rows)
return pd.DataFrame()

View File

@@ -101,16 +101,24 @@ NUMERIC_COLUMNS = [
"maths_avg_3yr",
]
# School type code to name mapping
# School type code to user-friendly name mapping
SCHOOL_TYPE_MAP = {
# Academies
"AC": "Academy",
"ACC": "Academy Converter",
"ACS": "Academy Sponsor Led",
"CY": "Community School",
"ACC": "Academy",
"ACCS": "Academy",
"ACS": "Academy (Sponsor Led)",
# Community Schools
"CY": "Community",
"CYS": "Community",
# Voluntary Schools
"VA": "Voluntary Aided",
"VC": "Voluntary Controlled",
# Foundation Schools
"FD": "Foundation",
"F": "Foundation",
"FDS": "Foundation",
# Free Schools
"FS": "Free School",
}
@@ -405,6 +413,10 @@ SCHOOL_COLUMNS = [
"address",
"town",
"postcode",
"religious_denomination",
"age_range",
"latitude",
"longitude",
]
# Local Authority code to name mapping (for fallback when LANAME column missing)

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,9 @@
<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">
<!-- Cookie Consent Banner -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.css">
@@ -80,7 +83,7 @@
</div>
</a>
<nav class="nav">
<a href="/" class="nav-link active" data-view="dashboard">Dashboard</a>
<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>
@@ -88,8 +91,8 @@
</header>
<main class="main">
<!-- Dashboard View -->
<section id="dashboard-view" class="view active">
<!-- 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>
@@ -137,9 +140,9 @@
<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="1" selected>1 mile</option>
<option value="0.5" selected>1/2 mile</option>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
<option value="5">5 miles</option>
</select>
<button id="location-search-btn" class="btn btn-primary location-btn">Find Nearby</button>
</div>
@@ -324,27 +327,87 @@
</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>
<div class="modal-footer">
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
<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">
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/" target="_blank">UK Government - Compare School Performance</a></p>
<p class="footer-note">Primary school (KS2) data for England. Data from 2019-2020, 2020-2021, 2021-2022 unavailable due to COVID-19 disruption.</p>
<div class="footer-content">
<div class="footer-contact">
<h3>Contact Us</h3>
<p>Have questions, feedback, or suggestions? We'd love to hear from you.</p>
<form action="https://formsubmit.co/contact@schoolcompare.co.uk" method="POST" class="contact-form">
<input type="hidden" name="_subject" value="SchoolCompare Contact Form">
<input type="hidden" name="_captcha" value="false">
<input type="text" name="_honey" style="display:none">
<div class="form-row">
<input type="text" name="name" placeholder="Your Name" required class="form-input">
<input type="email" name="email" placeholder="Your Email" required class="form-input">
</div>
<textarea name="message" placeholder="Your Message" required class="form-input form-textarea"></textarea>
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</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>
<!-- Google Analytics (loaded conditionally after consent) -->
<script>
var GA_MEASUREMENT_ID = null;
var analyticsConsentGiven = false;
function loadGoogleAnalytics() {
if (window.gaLoaded || !GA_MEASUREMENT_ID) return;
window.gaLoaded = true;
// Load gtag.js script
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_MEASUREMENT_ID;
document.head.appendChild(script);
// Initialize dataLayer and gtag function
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', GA_MEASUREMENT_ID);
}
// Fetch GA ID from server config, then load GA if consent already given
fetch('/api/config')
.then(function(response) { return response.json(); })
.then(function(config) {
if (config.ga_measurement_id) {
GA_MEASUREMENT_ID = config.ga_measurement_id;
// If consent was already given before config loaded, load GA now
if (analyticsConsentGiven) {
loadGoogleAnalytics();
}
}
})
.catch(function(err) { console.warn('Failed to load config:', err); });
</script>
<!-- Cookie Consent Banner -->
<script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script>
<script>
@@ -356,16 +419,41 @@
description: 'Essential cookies required for the website to function properly.',
required: true,
defaultValue: true
},
{
id: 'analytics',
label: 'Analytics',
description: 'Help us understand how visitors use our site so we can improve it.',
required: false,
defaultValue: false
}
],
text: {
title: 'Cookie Preferences',
description: 'This website does not use tracking cookies. We only use essential cookies required for the site to function.',
acceptAll: 'Accept',
description: 'We use cookies to improve your experience. Analytics cookies help us understand how you use the site.',
acceptAll: 'Accept All',
rejectAll: 'Reject All',
save: 'Save Preferences'
},
onConsentChange: function(consent) {
if (consent.analytics) {
analyticsConsentGiven = true;
loadGoogleAnalytics();
}
}
});
// Check existing consent state after initialization
(function() {
var manager = window.silktideConsentManager.getInstance();
if (manager) {
var analyticsConsent = manager.getConsentChoice('analytics');
if (analyticsConsent === true) {
analyticsConsentGiven = true;
loadGoogleAnalytics();
}
}
})();
</script>
</body>
</html>

View File

@@ -433,6 +433,8 @@ body {
}
.school-card {
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
@@ -441,6 +443,7 @@ body {
transition: var(--transition);
position: relative;
overflow: hidden;
z-index: 0;
}
.school-card::before {
@@ -493,18 +496,40 @@ body {
color: var(--accent-teal);
}
.school-tag.faith {
background: rgba(138, 43, 226, 0.1);
color: #8a2be2;
}
.school-address {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.school-details {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
flex-grow: 1;
}
.age-range {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.2rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.school-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
margin-top: auto;
}
.stat {
@@ -512,9 +537,13 @@ body {
}
.stat-value {
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.stat-value.positive {
@@ -525,11 +554,121 @@ body {
color: var(--accent-coral);
}
/* Trend indicators */
.trend-indicator {
font-size: 0.75rem;
cursor: help;
}
.trend-up {
color: var(--accent-teal);
}
.trend-down {
color: var(--accent-coral);
}
.trend-stable {
color: var(--text-muted);
font-size: 0.6rem;
}
.stat-label {
font-size: 0.7rem;
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.03em;
}
/* School Card Map */
.school-map {
height: 150px;
margin-top: 1rem;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
border: 1px solid var(--border-color);
transition: var(--transition);
}
.school-map:hover {
border-color: var(--accent-coral);
box-shadow: var(--shadow-small);
}
/* Fullscreen Map Modal */
.map-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
.map-modal {
background: var(--bg-card);
border-radius: var(--radius-lg);
width: 100%;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow-large);
animation: slideUp 0.3s ease;
}
.map-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.map-modal-header h3 {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.map-modal-close {
background: none;
border: none;
font-size: 1.75rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
transition: var(--transition);
}
.map-modal-close:hover {
color: var(--accent-coral);
}
.map-modal-content {
height: 500px;
width: 100%;
}
@media (max-width: 768px) {
.map-modal {
max-height: 80vh;
}
.map-modal-content {
height: 400px;
}
}
/* Section Titles */
@@ -854,7 +993,7 @@ body {
left: 0;
width: 100%;
height: 100%;
z-index: 200;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
@@ -920,6 +1059,7 @@ body {
}
.modal-header {
position: relative;
padding: 2rem 2rem 1rem;
border-bottom: 1px solid var(--border-color);
}
@@ -930,7 +1070,7 @@ body {
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
padding-right: 3rem;
padding-right: 10rem;
}
.modal-meta {
@@ -939,6 +1079,26 @@ body {
flex-wrap: wrap;
}
.modal-details {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.modal-details .modal-address {
margin-bottom: 0.25rem;
}
.modal-compare-btn {
position: absolute;
top: 1rem;
right: 4rem;
}
.modal-details .modal-age-range {
color: var(--text-muted);
}
.modal-body {
padding: 2rem;
}
@@ -994,13 +1154,25 @@ body {
margin-top: 0.25rem;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
.modal-map-container {
margin-top: 2rem;
}
.modal-map-container h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
}
.modal-map {
height: 200px;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
@@ -1025,14 +1197,82 @@ body {
/* Footer */
.footer {
text-align: center;
padding: 2rem;
margin-top: 3rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.85rem;
}
.footer-content {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.footer-contact {
margin-bottom: 2rem;
}
.footer-contact h3 {
font-family: 'Playfair Display', serif;
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.footer-contact > p {
color: var(--text-muted);
margin-bottom: 1rem;
}
.contact-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.contact-form .form-row {
display: flex;
gap: 0.75rem;
}
.contact-form .form-input {
flex: 1;
padding: 0.75rem 1rem;
font-family: inherit;
font-size: 0.9rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
transition: var(--transition);
}
.contact-form .form-input:focus {
outline: none;
border-color: var(--accent-teal);
box-shadow: 0 0 0 3px rgba(45, 106, 100, 0.1);
}
.contact-form .form-input::placeholder {
color: var(--text-muted);
}
.contact-form .form-textarea {
min-height: 100px;
resize: vertical;
}
.contact-form .btn {
align-self: flex-start;
}
.footer-source {
text-align: center;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.footer a {
color: var(--accent-teal);
text-decoration: none;
@@ -1042,9 +1282,14 @@ body {
text-decoration: underline;
}
.footer-note {
margin-top: 0.5rem;
font-size: 0.75rem;
@media (max-width: 768px) {
.contact-form .form-row {
flex-direction: column;
}
.contact-form .btn {
align-self: stretch;
}
}
/* Loading State */
@@ -1222,10 +1467,280 @@ body {
margin: 1rem;
max-height: calc(100vh - 2rem);
}
.modal-header {
display: flex;
flex-direction: column;
}
.modal-header h2 {
padding-right: 0;
order: 1;
}
.modal-meta {
order: 2;
}
.modal-details {
order: 3;
}
.modal-compare-btn {
position: static;
order: 4;
margin-top: 1rem;
width: 100%;
}
.modal-body {
padding: 1rem;
}
.modal-chart-container {
margin-bottom: 1.5rem;
padding: 0.5rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.modal-chart-container canvas {
max-height: 280px;
}
.modal-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.modal-stat {
padding: 0.75rem 0.5rem;
}
.modal-stat-value {
font-size: 1.25rem;
}
.modal-stat-label {
font-size: 0.65rem;
}
.rankings-controls {
flex-direction: column;
align-items: stretch;
}
}
/* Extra small screens */
@media (max-width: 480px) {
.modal-content {
margin: 0.5rem;
max-height: calc(100vh - 1rem);
}
.modal-header {
padding: 1rem;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-chart-container canvas {
max-height: 260px;
}
.modal-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.modal-stat-value {
font-size: 1.1rem;
}
.modal-map {
height: 160px;
}
}
/* =============================================================================
TOOLTIP SYSTEM
============================================================================= */
/* Info Icon Trigger */
.info-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 0.25rem;
background: none;
border: none;
cursor: help;
color: var(--text-muted);
opacity: 0.6;
transition: var(--transition);
vertical-align: middle;
border-radius: 50%;
}
.info-trigger:hover,
.info-trigger:focus {
color: var(--accent-teal);
opacity: 1;
}
.info-trigger:focus {
outline: none;
box-shadow: 0 0 0 2px var(--accent-teal);
}
.info-trigger:focus:not(:focus-visible) {
box-shadow: none;
}
.info-trigger:focus-visible {
box-shadow: 0 0 0 2px var(--accent-teal);
}
/* Info Icon SVG */
.info-icon {
width: 10px;
height: 10px;
flex-shrink: 0;
}
.modal-stat-label .info-icon {
width: 14px;
height: 14px;
}
/* Tooltip Container */
.tooltip {
position: absolute;
z-index: 3000;
min-width: 200px;
max-width: 280px;
padding: 0.75rem 1rem;
background: var(--bg-accent);
color: var(--text-inverse);
border-radius: var(--radius-md);
box-shadow: var(--shadow-medium);
font-family: 'DM Sans', sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
text-transform: none;
letter-spacing: normal;
text-align: left;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, visibility 150ms ease;
pointer-events: none;
}
.tooltip.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
/* Tooltip Arrow - Top Placement (arrow points down) */
.tooltip[data-placement="top"]::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: var(--bg-accent);
}
/* Tooltip Arrow - Bottom Placement (arrow points up) */
.tooltip[data-placement="bottom"]::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-bottom-color: var(--bg-accent);
}
/* Tooltip Title */
.tooltip-title {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
/* Tooltip Note (for context like national average) */
.tooltip-note {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(250, 247, 242, 0.2);
font-size: 0.75rem;
opacity: 0.8;
}
/* Warning Trigger Button */
.warning-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 0.5rem;
background: none;
border: none;
cursor: help;
color: var(--accent-coral);
opacity: 0.8;
transition: var(--transition);
vertical-align: middle;
border-radius: 4px;
}
.warning-trigger:hover,
.warning-trigger:focus {
opacity: 1;
}
.warning-trigger:focus {
outline: none;
box-shadow: 0 0 0 2px var(--accent-coral);
}
.warning-trigger:focus:not(:focus-visible) {
box-shadow: none;
}
.warning-trigger:focus-visible {
box-shadow: 0 0 0 2px var(--accent-coral);
}
/* Warning Icon SVG */
.warning-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* Warning Tooltip Styling */
.tooltip.tooltip-warning {
background: #8b4513;
border-left: 3px solid var(--accent-coral);
}
.tooltip.tooltip-warning[data-placement="top"]::after {
border-top-color: #8b4513;
}
.tooltip.tooltip-warning[data-placement="bottom"]::after {
border-bottom-color: #8b4513;
}
/* Label wrapper for inline icon */
.stat-label-with-info {
display: inline-flex;
align-items: center;
justify-content: center;
}

184
scripts/geocode_schools.py Executable file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Geocode all school postcodes and update the database.
This script should be run as a weekly cron job to ensure all schools
have up-to-date latitude/longitude coordinates.
Usage:
python scripts/geocode_schools.py [--force]
Options:
--force Re-geocode all postcodes, even if already geocoded
Crontab example (run every Sunday at 2am):
0 2 * * 0 cd /path/to/school_compare && /path/to/venv/bin/python scripts/geocode_schools.py >> /var/log/geocode_schools.log 2>&1
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, Tuple
import requests
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.database import SessionLocal
from backend.models import School
def geocode_postcodes_bulk(postcodes: list) -> Dict[str, Tuple[float, float]]:
"""
Geocode postcodes in bulk using postcodes.io API.
Returns dict of postcode -> (latitude, longitude).
"""
results = {}
valid_postcodes = [
p.strip().upper()
for p in postcodes
if p and isinstance(p, str) and len(p.strip()) >= 5
]
valid_postcodes = list(set(valid_postcodes))
if not valid_postcodes:
return results
batch_size = 100
total_batches = (len(valid_postcodes) + batch_size - 1) // batch_size
for i, batch_start in enumerate(range(0, len(valid_postcodes), batch_size)):
batch = valid_postcodes[batch_start : batch_start + batch_size]
print(f" Geocoding batch {i + 1}/{total_batches} ({len(batch)} postcodes)...")
try:
response = requests.post(
"https://api.postcodes.io/postcodes",
json={"postcodes": batch},
timeout=30,
)
if response.status_code == 200:
data = response.json()
for item in data.get("result", []):
if item and item.get("result"):
pc = item["query"].upper()
lat = item["result"].get("latitude")
lon = item["result"].get("longitude")
if lat and lon:
results[pc] = (lat, lon)
else:
print(f" Warning: API returned status {response.status_code}")
except Exception as e:
print(f" Warning: Geocoding batch failed: {e}")
return results
def geocode_schools(force: bool = False) -> None:
"""
Geocode all schools in the database.
Args:
force: If True, re-geocode all postcodes even if already geocoded
"""
print(f"\n{'='*60}")
print(f"School Geocoding Job - {datetime.now().isoformat()}")
print(f"{'='*60}\n")
db = SessionLocal()
try:
# Get schools that need geocoding
if force:
schools = db.query(School).filter(School.postcode.isnot(None)).all()
print(f"Force mode: Processing all {len(schools)} schools with postcodes")
else:
schools = db.query(School).filter(
School.postcode.isnot(None),
(School.latitude.is_(None)) | (School.longitude.is_(None))
).all()
print(f"Found {len(schools)} schools without coordinates")
if not schools:
print("No schools to geocode. Exiting.")
return
# Extract unique postcodes
postcodes = list(set(
s.postcode.strip().upper()
for s in schools
if s.postcode
))
print(f"Unique postcodes to geocode: {len(postcodes)}")
# Geocode in bulk
print("\nGeocoding postcodes...")
geocoded = geocode_postcodes_bulk(postcodes)
print(f"Successfully geocoded: {len(geocoded)} postcodes")
# Update database
print("\nUpdating database...")
updated_count = 0
failed_count = 0
for school in schools:
if not school.postcode:
continue
pc_upper = school.postcode.strip().upper()
coords = geocoded.get(pc_upper)
if coords:
school.latitude = coords[0]
school.longitude = coords[1]
updated_count += 1
else:
failed_count += 1
db.commit()
print(f"\nResults:")
print(f" - Updated: {updated_count} schools")
print(f" - Failed (invalid/not found): {failed_count} postcodes")
# Summary stats
total_with_coords = db.query(School).filter(
School.latitude.isnot(None),
School.longitude.isnot(None)
).count()
total_schools = db.query(School).count()
print(f"\nDatabase summary:")
print(f" - Total schools: {total_schools}")
print(f" - Schools with coordinates: {total_with_coords}")
print(f" - Coverage: {100*total_with_coords/total_schools:.1f}%")
except Exception as e:
print(f"Error during geocoding: {e}")
db.rollback()
raise
finally:
db.close()
print(f"\n{'='*60}")
print(f"Geocoding job completed - {datetime.now().isoformat()}")
print(f"{'='*60}\n")
def main():
parser = argparse.ArgumentParser(
description="Geocode school postcodes and update database"
)
parser.add_argument(
"--force",
action="store_true",
help="Re-geocode all postcodes, even if already geocoded"
)
args = parser.parse_args()
geocode_schools(force=args.force)
if __name__ == "__main__":
main()