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>
This commit is contained in:
Tudor
2026-01-12 15:55:23 +00:00
parent 708fbe83a0
commit 1a9341eaf4
5 changed files with 106 additions and 32 deletions

View File

@@ -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()

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",
}

View File

@@ -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 = `
<div class="empty-state">

View File

@@ -330,6 +330,7 @@
<h2 id="modal-school-name"></h2>
<div class="modal-meta" id="modal-meta"></div>
<div class="modal-details" id="modal-details"></div>
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
</div>
<div class="modal-body">
<div class="modal-chart-container">
@@ -341,9 +342,6 @@
<div class="modal-map" id="modal-map"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
</div>
</div>
</div>

View File

@@ -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 {