From 1a9341eaf419d26c9d0ff57dd33597be17f966ff Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 12 Jan 2026 15:55:23 +0000 Subject: [PATCH] Simplify school types and persist selected schools - 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 --- backend/data_loader.py | 78 ++++++++++++++++++++++++++++++++---------- backend/schemas.py | 16 ++++++--- frontend/app.js | 30 ++++++++++++++++ frontend/index.html | 4 +-- frontend/styles.css | 10 +++--- 5 files changed, 106 insertions(+), 32 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 86dcb46..1364c19 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -17,11 +17,39 @@ 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 user search postcode geocoding (not for school data) _postcode_cache: Dict[str, Tuple[float, float]] = {} +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: @@ -115,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() @@ -172,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 @@ -220,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() @@ -335,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 @@ -410,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: @@ -422,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, @@ -431,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() diff --git a/backend/schemas.py b/backend/schemas.py index 9a08c2b..a96abd8 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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", } diff --git a/frontend/app.js b/frontend/app.js index 913ef2e..6eaa211 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -345,6 +345,9 @@ function handleRoute() { document.addEventListener("DOMContentLoaded", init); async function init() { + // Load selected schools from localStorage first + loadSelectedSchoolsFromStorage(); + try { // Load filters and metrics in parallel (single request for filters) const [filtersData, metricsData] = await Promise.all([ @@ -376,6 +379,9 @@ async function init() { // Initialize tooltip manager tooltipManager = new TooltipManager(); + // Render any previously selected schools + renderSelectedSchools(); + // Handle initial route handleRoute(); @@ -1544,14 +1550,38 @@ function addToComparison(school) { } state.selectedSchools.push(school); + saveSelectedSchoolsToStorage(); renderSelectedSchools(); } function removeFromComparison(urn) { state.selectedSchools = state.selectedSchools.filter((s) => s.urn !== urn); + saveSelectedSchoolsToStorage(); renderSelectedSchools(); } +function saveSelectedSchoolsToStorage() { + try { + localStorage.setItem("selectedSchools", JSON.stringify(state.selectedSchools)); + } catch (e) { + console.warn("Failed to save to localStorage:", e); + } +} + +function loadSelectedSchoolsFromStorage() { + try { + const stored = localStorage.getItem("selectedSchools"); + if (stored) { + const schools = JSON.parse(stored); + if (Array.isArray(schools)) { + state.selectedSchools = schools; + } + } + } catch (e) { + console.warn("Failed to load from localStorage:", e); + } +} + function showEmptyState(container, message) { container.innerHTML = `
diff --git a/frontend/index.html b/frontend/index.html index f05bde1..3f90f6f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -330,6 +330,7 @@ +
- diff --git a/frontend/styles.css b/frontend/styles.css index 5f772c9..00e907a 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1088,6 +1088,10 @@ body { margin-bottom: 0.25rem; } +.modal-header .btn { + margin-top: 1rem; +} + .modal-details .modal-age-range { color: var(--text-muted); } @@ -1165,12 +1169,6 @@ body { cursor: pointer; } -.modal-footer { - padding: 1.5rem 2rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; -} /* Buttons */ .btn {