diff --git a/frontend/app.js b/frontend/app.js index 8d1844e..2952b9e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3,32 +3,32 @@ * Interactive UK Primary School Performance Comparison */ -const API_BASE = ''; +const API_BASE = ""; // ============================================================================= // STATE MANAGEMENT // ============================================================================= const state = { - schools: [], - selectedSchools: [], - currentSchoolData: null, - filters: null, // Cached filter data - metrics: null, // Cached metric definitions - pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 }, - isShowingFeatured: true, // Whether showing featured schools vs search results - locationSearch: { - active: false, - postcode: null, - radius: 5, - }, - loading: { - schools: false, - filters: false, - rankings: false, - comparison: false, - modal: false, - }, + schools: [], + selectedSchools: [], + currentSchoolData: null, + filters: null, // Cached filter data + metrics: null, // Cached metric definitions + pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 }, + isShowingFeatured: true, // Whether showing featured schools vs search results + locationSearch: { + active: false, + postcode: null, + radius: 5, + }, + loading: { + schools: false, + filters: false, + rankings: false, + comparison: false, + modal: false, + }, }; // Charts @@ -37,14 +37,14 @@ let schoolDetailChart = null; // Chart colors const CHART_COLORS = [ - '#e07256', // coral - '#2d7d7d', // teal - '#c9a227', // gold - '#7b68a6', // purple - '#3498db', // blue - '#27ae60', // green - '#e74c3c', // red - '#9b59b6', // violet + "#e07256", // coral + "#2d7d7d", // teal + "#c9a227", // gold + "#7b68a6", // purple + "#3498db", // blue + "#27ae60", // green + "#e74c3c", // red + "#9b59b6", // violet ]; // ============================================================================= @@ -52,34 +52,34 @@ const CHART_COLORS = [ // ============================================================================= const elements = { - schoolSearch: document.getElementById('school-search'), - localAuthorityFilter: document.getElementById('local-authority-filter'), - typeFilter: document.getElementById('type-filter'), - postcodeSearch: document.getElementById('postcode-search'), - radiusSelect: document.getElementById('radius-select'), - locationSearchBtn: document.getElementById('location-search-btn'), - clearLocationBtn: document.getElementById('clear-location-btn'), - schoolsGrid: document.getElementById('schools-grid'), - compareSearch: document.getElementById('compare-search'), - compareResults: document.getElementById('compare-results'), - selectedSchools: document.getElementById('selected-schools'), - chartsSection: document.getElementById('charts-section'), - metricSelect: document.getElementById('metric-select'), - comparisonChart: document.getElementById('comparison-chart'), - comparisonTable: document.getElementById('comparison-table'), - tableHeader: document.getElementById('table-header'), - tableBody: document.getElementById('table-body'), - rankingArea: document.getElementById('ranking-area'), - rankingMetric: document.getElementById('ranking-metric'), - rankingYear: document.getElementById('ranking-year'), - rankingsList: document.getElementById('rankings-list'), - modal: document.getElementById('school-modal'), - modalClose: document.getElementById('modal-close'), - modalSchoolName: document.getElementById('modal-school-name'), - modalMeta: document.getElementById('modal-meta'), - modalStats: document.getElementById('modal-stats'), - schoolDetailChart: document.getElementById('school-detail-chart'), - addToCompare: document.getElementById('add-to-compare'), + schoolSearch: document.getElementById("school-search"), + localAuthorityFilter: document.getElementById("local-authority-filter"), + typeFilter: document.getElementById("type-filter"), + postcodeSearch: document.getElementById("postcode-search"), + radiusSelect: document.getElementById("radius-select"), + locationSearchBtn: document.getElementById("location-search-btn"), + clearLocationBtn: document.getElementById("clear-location-btn"), + schoolsGrid: document.getElementById("schools-grid"), + compareSearch: document.getElementById("compare-search"), + compareResults: document.getElementById("compare-results"), + selectedSchools: document.getElementById("selected-schools"), + chartsSection: document.getElementById("charts-section"), + metricSelect: document.getElementById("metric-select"), + comparisonChart: document.getElementById("comparison-chart"), + comparisonTable: document.getElementById("comparison-table"), + tableHeader: document.getElementById("table-header"), + tableBody: document.getElementById("table-body"), + rankingArea: document.getElementById("ranking-area"), + rankingMetric: document.getElementById("ranking-metric"), + rankingYear: document.getElementById("ranking-year"), + rankingsList: document.getElementById("rankings-list"), + modal: document.getElementById("school-modal"), + modalClose: document.getElementById("modal-close"), + modalSchoolName: document.getElementById("modal-school-name"), + modalMeta: document.getElementById("modal-meta"), + modalStats: document.getElementById("modal-stats"), + schoolDetailChart: document.getElementById("school-detail-chart"), + addToCompare: document.getElementById("add-to-compare"), }; // ============================================================================= @@ -90,70 +90,74 @@ const apiCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes async function fetchAPI(endpoint, options = {}) { - const { useCache = true, showLoading = null } = options; - const cacheKey = endpoint; - - // Check cache - if (useCache && apiCache.has(cacheKey)) { - const cached = apiCache.get(cacheKey); - if (Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data; - } - apiCache.delete(cacheKey); + const { useCache = true, showLoading = null } = options; + const cacheKey = endpoint; + + // Check cache + if (useCache && apiCache.has(cacheKey)) { + const cached = apiCache.get(cacheKey); + if (Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; } - - // Set loading state + apiCache.delete(cacheKey); + } + + // Set loading state + if (showLoading) { + state.loading[showLoading] = true; + updateLoadingUI(showLoading); + } + + try { + const response = await fetch(`${API_BASE}${endpoint}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + // Cache the response + if (useCache) { + apiCache.set(cacheKey, { data, timestamp: Date.now() }); + } + + return data; + } catch (error) { + console.error(`API Error (${endpoint}):`, error); + return null; + } finally { if (showLoading) { - state.loading[showLoading] = true; - updateLoadingUI(showLoading); - } - - try { - const response = await fetch(`${API_BASE}${endpoint}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); - - // Cache the response - if (useCache) { - apiCache.set(cacheKey, { data, timestamp: Date.now() }); - } - - return data; - } catch (error) { - console.error(`API Error (${endpoint}):`, error); - return null; - } finally { - if (showLoading) { - state.loading[showLoading] = false; - updateLoadingUI(showLoading); - } + state.loading[showLoading] = false; + updateLoadingUI(showLoading); } + } } function updateLoadingUI(section) { - // Update UI based on loading state - switch (section) { - case 'schools': - if (state.loading.schools) { - elements.schoolsGrid.innerHTML = renderLoadingSkeleton(6); - } - break; - case 'rankings': - if (state.loading.rankings) { - elements.rankingsList.innerHTML = renderLoadingSkeleton(5, 'ranking'); - } - break; - case 'modal': - if (state.loading.modal) { - elements.modalStats.innerHTML = '

Loading school data...

'; - } - break; - } + // Update UI based on loading state + switch (section) { + case "schools": + if (state.loading.schools) { + elements.schoolsGrid.innerHTML = renderLoadingSkeleton(6); + } + break; + case "rankings": + if (state.loading.rankings) { + elements.rankingsList.innerHTML = renderLoadingSkeleton(5, "ranking"); + } + break; + case "modal": + if (state.loading.modal) { + elements.modalStats.innerHTML = + '

Loading school data...

'; + } + break; + } } -function renderLoadingSkeleton(count, type = 'card') { - if (type === 'ranking') { - return Array(count).fill(0).map(() => ` +function renderLoadingSkeleton(count, type = "card") { + if (type === "ranking") { + return Array(count) + .fill(0) + .map( + () => `
@@ -162,15 +166,22 @@ function renderLoadingSkeleton(count, type = 'card') {
- `).join(''); - } - return Array(count).fill(0).map(() => ` + `, + ) + .join(""); + } + return Array(count) + .fill(0) + .map( + () => `
- `).join(''); + `, + ) + .join(""); } // ============================================================================= @@ -178,103 +189,107 @@ function renderLoadingSkeleton(count, type = 'card') { // ============================================================================= const routes = { - '/': 'dashboard', - '/compare': 'compare', - '/rankings': 'rankings', + "/": "dashboard", + "/compare": "compare", + "/rankings": "rankings", }; function navigateTo(path) { - // Update URL without reload - window.history.pushState({}, '', path); - handleRoute(); + // Update URL without reload + window.history.pushState({}, "", path); + handleRoute(); } function handleRoute() { - const path = window.location.pathname; - const view = routes[path] || 'dashboard'; - - // Update navigation - document.querySelectorAll('.nav-link').forEach(link => { - link.classList.toggle('active', link.dataset.view === view); - }); - - // Update view - document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); - const viewElement = document.getElementById(`${view}-view`); - if (viewElement) { - viewElement.classList.add('active'); - } + const path = window.location.pathname; + const view = routes[path] || "dashboard"; + + // Update navigation + document.querySelectorAll(".nav-link").forEach((link) => { + link.classList.toggle("active", link.dataset.view === view); + }); + + // Update view + document + .querySelectorAll(".view") + .forEach((v) => v.classList.remove("active")); + const viewElement = document.getElementById(`${view}-view`); + if (viewElement) { + viewElement.classList.add("active"); + } } // ============================================================================= // INITIALIZATION // ============================================================================= -document.addEventListener('DOMContentLoaded', init); +document.addEventListener("DOMContentLoaded", init); async function init() { - // Load filters and metrics in parallel (single request for filters) - const [filtersData, metricsData] = await Promise.all([ - fetchAPI('/api/filters', { showLoading: 'filters' }), - fetchAPI('/api/metrics'), - ]); - - // Cache and apply filters - if (filtersData) { - state.filters = filtersData; - populateFilters(filtersData); - } - - // Cache metrics - if (metricsData) { - state.metrics = metricsData.metrics; - } - - // Load initial data - await loadSchools(); - await loadRankings(); - - setupEventListeners(); - - // Handle initial route - handleRoute(); - - // Handle browser back/forward - window.addEventListener('popstate', handleRoute); + // Load filters and metrics in parallel (single request for filters) + const [filtersData, metricsData] = await Promise.all([ + fetchAPI("/api/filters", { showLoading: "filters" }), + fetchAPI("/api/metrics"), + ]); + + // Cache and apply filters + if (filtersData) { + state.filters = filtersData; + populateFilters(filtersData); + } + + // Cache metrics + if (metricsData) { + state.metrics = metricsData.metrics; + } + + // Load initial data + await loadSchools(); + await loadRankings(); + + setupEventListeners(); + + // Handle initial route + handleRoute(); + + // Handle browser back/forward + window.addEventListener("popstate", handleRoute); } function populateFilters(data) { - // Populate local authority filter (dashboard) - data.local_authorities.forEach(la => { - const option = document.createElement('option'); - option.value = la; - option.textContent = la; - elements.localAuthorityFilter.appendChild(option); - }); + // Populate local authority filter (dashboard) + data.local_authorities.forEach((la) => { + const option = document.createElement("option"); + option.value = la; + option.textContent = la; + elements.localAuthorityFilter.appendChild(option); + }); - // Populate school type filter - data.school_types.forEach(type => { - const option = document.createElement('option'); - option.value = type; - option.textContent = type; - elements.typeFilter.appendChild(option); - }); - - // Populate ranking area dropdown - data.local_authorities.forEach(la => { - const option = document.createElement('option'); - option.value = la; - option.textContent = la; - elements.rankingArea.appendChild(option); - }); - - // Populate ranking year dropdown - elements.rankingYear.innerHTML = ''; - data.years.sort((a, b) => b - a).forEach(year => { - const option = document.createElement('option'); - option.value = year; - option.textContent = `${year}`; - elements.rankingYear.appendChild(option); + // Populate school type filter + data.school_types.forEach((type) => { + const option = document.createElement("option"); + option.value = type; + option.textContent = type; + elements.typeFilter.appendChild(option); + }); + + // Populate ranking area dropdown + data.local_authorities.forEach((la) => { + const option = document.createElement("option"); + option.value = la; + option.textContent = la; + elements.rankingArea.appendChild(option); + }); + + // Populate ranking year dropdown + elements.rankingYear.innerHTML = ""; + data.years + .sort((a, b) => b - a) + .forEach((year) => { + const option = document.createElement("option"); + option.value = year; + option.textContent = `${year}`; + elements.rankingYear.appendChild(option); }); } @@ -283,164 +298,171 @@ function populateFilters(data) { // ============================================================================= async function loadSchools() { - const search = elements.schoolSearch.value.trim(); - const localAuthority = elements.localAuthorityFilter.value; - const type = elements.typeFilter.value; - const { active: locationActive, postcode, radius } = state.locationSearch; - - // If no search query (or less than 2 chars) and no filters and no location search, show featured schools - if (search.length < 2 && !localAuthority && !type && !locationActive) { - await loadFeaturedSchools(); - return; - } - - const params = new URLSearchParams(); - - if (search.length >= 2) params.append('search', search); - if (localAuthority) params.append('local_authority', localAuthority); - if (type) params.append('school_type', type); - - // Add location search params - if (locationActive && postcode) { - params.append('postcode', postcode); - params.append('radius', radius); - } - - params.append('page', state.pagination.page); - params.append('page_size', state.pagination.pageSize); - - const queryString = params.toString(); - const endpoint = `/api/schools?${queryString}`; - - // Don't cache search results (they change based on input) - const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'schools' }); - - if (!data) { - showEmptyState(elements.schoolsGrid, 'Unable to load schools'); - return; - } - - state.schools = data.schools; - state.pagination.total = data.total; - state.pagination.totalPages = data.total_pages; - state.isShowingFeatured = false; - - // Show location info banner if location search is active - updateLocationInfoBanner(data.search_location); - - renderSchools(state.schools); + const search = elements.schoolSearch.value.trim(); + const localAuthority = elements.localAuthorityFilter.value; + const type = elements.typeFilter.value; + const { active: locationActive, postcode, radius } = state.locationSearch; + + // If no search query (or less than 2 chars) and no filters and no location search, show featured schools + if (search.length < 2 && !localAuthority && !type && !locationActive) { + await loadFeaturedSchools(); + return; + } + + const params = new URLSearchParams(); + + if (search.length >= 2) params.append("search", search); + if (localAuthority) params.append("local_authority", localAuthority); + if (type) params.append("school_type", type); + + // Add location search params + if (locationActive && postcode) { + params.append("postcode", postcode); + params.append("radius", radius); + } + + params.append("page", state.pagination.page); + params.append("page_size", state.pagination.pageSize); + + const queryString = params.toString(); + const endpoint = `/api/schools?${queryString}`; + + // Don't cache search results (they change based on input) + const data = await fetchAPI(endpoint, { + useCache: false, + showLoading: "schools", + }); + + if (!data) { + showEmptyState(elements.schoolsGrid, "Unable to load schools"); + return; + } + + state.schools = data.schools; + state.pagination.total = data.total; + state.pagination.totalPages = data.total_pages; + state.isShowingFeatured = false; + + // Show location info banner if location search is active + updateLocationInfoBanner(data.search_location); + + renderSchools(state.schools); } async function loadFeaturedSchools() { - // Clear location info when showing featured - updateLocationInfoBanner(null); - - // Load a sample of schools and pick 3 random ones - const data = await fetchAPI('/api/schools?page_size=100', { showLoading: 'schools' }); - - if (!data || !data.schools.length) { - showEmptyState(elements.schoolsGrid, 'No schools available'); - return; - } - - // Shuffle and pick 3 random schools - const shuffled = data.schools.sort(() => Math.random() - 0.5); - state.schools = shuffled.slice(0, 3); - state.isShowingFeatured = true; - - renderFeaturedSchools(state.schools); + // Clear location info when showing featured + updateLocationInfoBanner(null); + + // Load a sample of schools and pick 3 random ones + const data = await fetchAPI("/api/schools?page_size=100", { + showLoading: "schools", + }); + + if (!data || !data.schools.length) { + showEmptyState(elements.schoolsGrid, "No schools available"); + return; + } + + // Shuffle and pick 3 random schools + const shuffled = data.schools.sort(() => Math.random() - 0.5); + state.schools = shuffled.slice(0, 3); + state.isShowingFeatured = true; + + renderFeaturedSchools(state.schools); } function updateLocationInfoBanner(searchLocation) { - // Remove existing banner if any - const existingBanner = document.querySelector('.location-info'); - if (existingBanner) { - existingBanner.remove(); - } - - if (!searchLocation) { - return; - } - - // Create location info banner - const banner = document.createElement('div'); - banner.className = 'location-info'; - banner.innerHTML = ` + // Remove existing banner if any + const existingBanner = document.querySelector(".location-info"); + if (existingBanner) { + existingBanner.remove(); + } + + if (!searchLocation) { + return; + } + + // Create location info banner + const banner = document.createElement("div"); + banner.className = "location-info"; + banner.innerHTML = ` Showing schools within ${searchLocation.radius} miles of ${searchLocation.postcode.toUpperCase()} `; - - // Insert banner before the schools grid - elements.schoolsGrid.parentNode.insertBefore(banner, elements.schoolsGrid); + + // Insert banner before the schools grid + elements.schoolsGrid.parentNode.insertBefore(banner, elements.schoolsGrid); } async function searchByLocation() { - const postcode = elements.postcodeSearch.value.trim(); - const radius = parseFloat(elements.radiusSelect.value); - - if (!postcode) { - alert('Please enter a postcode'); - return; - } - - // Validate UK postcode format (basic check) - const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i; - if (!postcodeRegex.test(postcode)) { - alert('Please enter a valid UK postcode (e.g. SW18 4TF)'); - return; - } - - // Update state - state.locationSearch = { - active: true, - postcode: postcode, - radius: radius, - }; - state.pagination.page = 1; - - // Show clear button - elements.clearLocationBtn.style.display = 'inline-flex'; - - // Load schools with location filter - await loadSchools(); + const postcode = elements.postcodeSearch.value.trim(); + const radius = parseFloat(elements.radiusSelect.value); + + if (!postcode) { + alert("Please enter a postcode"); + return; + } + + // Validate UK postcode format (basic check) + const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i; + if (!postcodeRegex.test(postcode)) { + alert("Please enter a valid UK postcode (e.g. SW18 4TF)"); + return; + } + + // Update state + state.locationSearch = { + active: true, + postcode: postcode, + radius: radius, + }; + state.pagination.page = 1; + + // Show clear button + elements.clearLocationBtn.style.display = "inline-flex"; + + // Load schools with location filter + await loadSchools(); } function clearLocationSearch() { - state.locationSearch = { - active: false, - postcode: null, - radius: 5, - }; - state.pagination.page = 1; - - // Clear input - elements.postcodeSearch.value = ''; - elements.radiusSelect.value = '5'; - - // Hide clear button - elements.clearLocationBtn.style.display = 'none'; - - // Reload schools (will show featured if no other filters) - loadSchools(); + state.locationSearch = { + active: false, + postcode: null, + radius: 5, + }; + state.pagination.page = 1; + + // Clear input + elements.postcodeSearch.value = ""; + elements.radiusSelect.value = "5"; + + // Hide clear button + elements.clearLocationBtn.style.display = "none"; + + // Reload schools (will show featured if no other filters) + loadSchools(); } function renderFeaturedSchools(schools) { - elements.schoolsGrid.innerHTML = ` + elements.schoolsGrid.innerHTML = ` - ${schools.map(school => ` + ${schools + .map( + (school) => ` - `).join('')} + `, + ) + .join("")} `; - - // Add click handlers - elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => { - card.addEventListener('click', () => { - const urn = parseInt(card.dataset.urn); - openSchoolModal(urn); - }); + + // Add click handlers + elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => { + card.addEventListener("click", () => { + const urn = parseInt(card.dataset.urn); + openSchoolModal(urn); }); + }); } async function loadSchoolDetails(urn) { - const data = await fetchAPI(`/api/schools/${urn}`, { showLoading: 'modal' }); - return data; + const data = await fetchAPI(`/api/schools/${urn}`, { showLoading: "modal" }); + return data; } async function loadComparison() { - if (state.selectedSchools.length === 0) return null; - - const urns = state.selectedSchools.map(s => s.urn).join(','); - const data = await fetchAPI(`/api/compare?urns=${urns}`, { useCache: false, showLoading: 'comparison' }); - return data; + if (state.selectedSchools.length === 0) return null; + + const urns = state.selectedSchools.map((s) => s.urn).join(","); + const data = await fetchAPI(`/api/compare?urns=${urns}`, { + useCache: false, + showLoading: "comparison", + }); + return data; } async function loadRankings() { - const area = elements.rankingArea.value; - const metric = elements.rankingMetric.value; - const year = elements.rankingYear.value; - - let endpoint = `/api/rankings?metric=${metric}&limit=20`; - if (year) endpoint += `&year=${year}`; - if (area) endpoint += `&local_authority=${encodeURIComponent(area)}`; - - const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'rankings' }); - - if (!data) { - showEmptyState(elements.rankingsList, 'Unable to load rankings'); - return; - } - - renderRankings(data.rankings, metric); + const area = elements.rankingArea.value; + const metric = elements.rankingMetric.value; + const year = elements.rankingYear.value; + + let endpoint = `/api/rankings?metric=${metric}&limit=20`; + if (year) endpoint += `&year=${year}`; + if (area) endpoint += `&local_authority=${encodeURIComponent(area)}`; + + const data = await fetchAPI(endpoint, { + useCache: false, + showLoading: "rankings", + }); + + if (!data) { + showEmptyState(elements.rankingsList, "Unable to load rankings"); + return; + } + + renderRankings(data.rankings, metric); } // ============================================================================= @@ -501,29 +531,30 @@ async function loadRankings() { // ============================================================================= function getMetricLabel(key, short = false) { - if (state.metrics) { - const metric = state.metrics.find(m => m.key === key); - if (metric) { - return short ? metric.short_name : metric.name; - } + if (state.metrics) { + const metric = state.metrics.find((m) => m.key === key); + if (metric) { + return short ? metric.short_name : metric.name; } - // Fallback labels - return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } + // Fallback labels + return key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } function formatMetricValue(value, metric) { - if (value === null || value === undefined) return '-'; - - const metricDef = state.metrics?.find(m => m.key === metric); - const type = metricDef?.type || (metric.includes('pct') ? 'percentage' : 'score'); - - if (metric.includes('progress')) { - return (value >= 0 ? '+' : '') + value.toFixed(1); - } - if (type === 'percentage' || metric.includes('pct')) { - return value.toFixed(0) + '%'; - } - return value.toFixed(1); + if (value === null || value === undefined) return "-"; + + const metricDef = state.metrics?.find((m) => m.key === metric); + const type = + metricDef?.type || (metric.includes("pct") ? "percentage" : "score"); + + if (metric.includes("progress")) { + return (value >= 0 ? "+" : "") + value.toFixed(1); + } + if (type === "percentage" || metric.includes("pct")) { + return value.toFixed(0) + "%"; + } + return value.toFixed(1); } // ============================================================================= @@ -531,27 +562,29 @@ function formatMetricValue(value, metric) { // ============================================================================= function renderSchools(schools) { - if (schools.length === 0) { - const message = state.locationSearch.active - ? `No schools found within ${state.locationSearch.radius} miles of ${state.locationSearch.postcode}` - : 'No primary schools found matching your criteria'; - showEmptyState(elements.schoolsGrid, message); - return; - } - - let html = schools.map(school => { - const distanceBadge = school.distance !== undefined && school.distance !== null - ? `${school.distance.toFixed(1)} mi` - : ''; - - return ` + if (schools.length === 0) { + const message = state.locationSearch.active + ? `No schools found within ${state.locationSearch.radius} miles of ${state.locationSearch.postcode}` + : "No primary schools found matching your criteria"; + showEmptyState(elements.schoolsGrid, message); + return; + } + + let html = schools + .map((school) => { + const distanceBadge = + school.distance !== undefined && school.distance !== null + ? `${school.distance.toFixed(1)} mi` + : ""; + + return `

${escapeHtml(school.school_name)}${distanceBadge}

- ${escapeHtml(school.local_authority || '')} - ${escapeHtml(school.school_type || '')} + ${escapeHtml(school.local_authority || "")} + ${escapeHtml(school.school_type || "")}
-
${escapeHtml(school.address || '')}
+
${escapeHtml(school.address || "")}
Primary
@@ -564,70 +597,80 @@ function renderSchools(schools) {
`; - }).join(''); - - // Add pagination info - if (state.pagination.totalPages > 1) { - html += ` + }) + .join(""); + + // Add pagination info + if (state.pagination.totalPages > 1) { + html += `
Showing ${schools.length} of ${state.pagination.total} schools - ${state.pagination.page < state.pagination.totalPages ? - `` : ''} + ${ + state.pagination.page < state.pagination.totalPages + ? `` + : "" + }
`; - } - - elements.schoolsGrid.innerHTML = html; - - // Add click handlers - elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => { - card.addEventListener('click', () => { - const urn = parseInt(card.dataset.urn); - openSchoolModal(urn); - }); + } + + elements.schoolsGrid.innerHTML = html; + + // Add click handlers + elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => { + card.addEventListener("click", () => { + const urn = parseInt(card.dataset.urn); + openSchoolModal(urn); }); + }); } async function loadMoreSchools() { - state.pagination.page++; - const params = new URLSearchParams(); - - const search = elements.schoolSearch.value.trim(); - if (search) params.append('search', search); - - const localAuthority = elements.localAuthorityFilter.value; - if (localAuthority) params.append('local_authority', localAuthority); - - const type = elements.typeFilter.value; - if (type) params.append('school_type', type); - - params.append('page', state.pagination.page); - params.append('page_size', state.pagination.pageSize); - - const data = await fetchAPI(`/api/schools?${params.toString()}`, { useCache: false }); - - if (data && data.schools.length > 0) { - state.schools = [...state.schools, ...data.schools]; - renderSchools(state.schools); - } + state.pagination.page++; + const params = new URLSearchParams(); + + const search = elements.schoolSearch.value.trim(); + if (search) params.append("search", search); + + const localAuthority = elements.localAuthorityFilter.value; + if (localAuthority) params.append("local_authority", localAuthority); + + const type = elements.typeFilter.value; + if (type) params.append("school_type", type); + + params.append("page", state.pagination.page); + params.append("page_size", state.pagination.pageSize); + + const data = await fetchAPI(`/api/schools?${params.toString()}`, { + useCache: false, + }); + + if (data && data.schools.length > 0) { + state.schools = [...state.schools, ...data.schools]; + renderSchools(state.schools); + } } function renderRankings(rankings, metric) { - if (rankings.length === 0) { - showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric'); - return; - } - - elements.rankingsList.innerHTML = rankings.map((school, index) => { - const value = school[metric]; - if (value === null || value === undefined) return ''; - - return ` + if (rankings.length === 0) { + showEmptyState( + elements.rankingsList, + "No ranking data available for this year/metric", + ); + return; + } + + elements.rankingsList.innerHTML = rankings + .map((school, index) => { + const value = school[metric]; + if (value === null || value === undefined) return ""; + + return `
-
${index + 1}
+
${index + 1}
${escapeHtml(school.school_name)}
-
${escapeHtml(school.local_authority || '')}
+
${escapeHtml(school.local_authority || "")}
${formatMetricValue(value, metric)}
@@ -635,20 +678,22 @@ function renderRankings(rankings, metric) {
`; - }).filter(Boolean).join(''); - - // Add click handlers - elements.rankingsList.querySelectorAll('.ranking-item').forEach(item => { - item.addEventListener('click', () => { - const urn = parseInt(item.dataset.urn); - openSchoolModal(urn); - }); + }) + .filter(Boolean) + .join(""); + + // Add click handlers + elements.rankingsList.querySelectorAll(".ranking-item").forEach((item) => { + item.addEventListener("click", () => { + const urn = parseInt(item.dataset.urn); + openSchoolModal(urn); }); + }); } function renderSelectedSchools() { - if (state.selectedSchools.length === 0) { - elements.selectedSchools.innerHTML = ` + if (state.selectedSchools.length === 0) { + elements.selectedSchools.innerHTML = `
@@ -661,11 +706,13 @@ function renderSelectedSchools() {

Search and add schools to compare

`; - elements.chartsSection.style.display = 'none'; - return; - } - - elements.selectedSchools.innerHTML = state.selectedSchools.map((school, index) => ` + elements.chartsSection.style.display = "none"; + return; + } + + elements.selectedSchools.innerHTML = state.selectedSchools + .map( + (school, index) => `
${escapeHtml(school.school_name)}
- `).join(''); - - // Add remove handlers - elements.selectedSchools.querySelectorAll('.remove').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const urn = parseInt(btn.dataset.urn); - removeFromComparison(urn); - }); + `, + ) + .join(""); + + // Add remove handlers + elements.selectedSchools.querySelectorAll(".remove").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const urn = parseInt(btn.dataset.urn); + removeFromComparison(urn); }); - - elements.chartsSection.style.display = 'block'; - updateComparisonChart(); + }); + + elements.chartsSection.style.display = "block"; + updateComparisonChart(); } async function updateComparisonChart() { - if (state.selectedSchools.length === 0) return; - - const data = await loadComparison(); - if (!data) return; - - const metric = elements.metricSelect.value; - - // Prepare chart data - const datasets = []; - const allYears = new Set(); - - state.selectedSchools.forEach((school, index) => { - const schoolData = data.comparison[school.urn]; - if (!schoolData) return; - - const yearlyData = schoolData.yearly_data; - yearlyData.forEach(d => allYears.add(d.year)); - - const sortedData = yearlyData.sort((a, b) => a.year - b.year); - - datasets.push({ - label: schoolData.school_info.school_name, - data: sortedData.map(d => ({ x: d.year, y: d[metric] })), - borderColor: CHART_COLORS[index % CHART_COLORS.length], - backgroundColor: CHART_COLORS[index % CHART_COLORS.length] + '20', - borderWidth: 3, - pointRadius: 5, - pointHoverRadius: 7, - tension: 0.3, - fill: false, - }); + if (state.selectedSchools.length === 0) return; + + const data = await loadComparison(); + if (!data) return; + + const metric = elements.metricSelect.value; + + // Prepare chart data + const datasets = []; + const allYears = new Set(); + + state.selectedSchools.forEach((school, index) => { + const schoolData = data.comparison[school.urn]; + if (!schoolData) return; + + const yearlyData = schoolData.yearly_data; + yearlyData.forEach((d) => allYears.add(d.year)); + + const sortedData = yearlyData.sort((a, b) => a.year - b.year); + + datasets.push({ + label: schoolData.school_info.school_name, + data: sortedData.map((d) => ({ x: d.year, y: d[metric] })), + borderColor: CHART_COLORS[index % CHART_COLORS.length], + backgroundColor: CHART_COLORS[index % CHART_COLORS.length] + "20", + borderWidth: 3, + pointRadius: 5, + pointHoverRadius: 7, + tension: 0.3, + fill: false, }); - - const years = Array.from(allYears).sort(); - - // Destroy existing chart - if (comparisonChart) { - comparisonChart.destroy(); - } - - // Create new chart - const ctx = elements.comparisonChart.getContext('2d'); - comparisonChart = new Chart(ctx, { - type: 'line', - data: { - labels: years, - datasets: datasets, + }); + + const years = Array.from(allYears).sort(); + + // Destroy existing chart + if (comparisonChart) { + comparisonChart.destroy(); + } + + // Create new chart + const ctx = elements.comparisonChart.getContext("2d"); + comparisonChart = new Chart(ctx, { + type: "line", + data: { + labels: years, + datasets: datasets, + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2, + plugins: { + legend: { + position: "bottom", + labels: { + font: { family: "'DM Sans', sans-serif", size: 12 }, + padding: 20, + usePointStyle: true, + }, }, - options: { - responsive: true, - maintainAspectRatio: true, - aspectRatio: 2, - plugins: { - legend: { - position: 'bottom', - labels: { - font: { family: "'DM Sans', sans-serif", size: 12 }, - padding: 20, - usePointStyle: true, - }, - }, - title: { - display: true, - text: getMetricLabel(metric), - font: { family: "'Playfair Display', serif", size: 18, weight: 600 }, - padding: { bottom: 20 }, - }, - tooltip: { - backgroundColor: '#1a1612', - titleFont: { family: "'DM Sans', sans-serif" }, - bodyFont: { family: "'DM Sans', sans-serif" }, - padding: 12, - cornerRadius: 8, - }, - }, - scales: { - x: { - title: { - display: true, - text: 'Academic Year', - font: { family: "'DM Sans', sans-serif", weight: 500 }, - }, - grid: { display: false }, - }, - y: { - title: { - display: true, - text: getMetricLabel(metric), - font: { family: "'DM Sans', sans-serif", weight: 500 }, - }, - grid: { color: '#e5dfd5' }, - }, - }, - interaction: { - intersect: false, - mode: 'index', - }, + title: { + display: true, + text: getMetricLabel(metric), + font: { family: "'Playfair Display', serif", size: 18, weight: 600 }, + padding: { bottom: 20 }, }, - }); - - // Update comparison table - updateComparisonTable(data.comparison, metric, years); + tooltip: { + backgroundColor: "#1a1612", + titleFont: { family: "'DM Sans', sans-serif" }, + bodyFont: { family: "'DM Sans', sans-serif" }, + padding: 12, + cornerRadius: 8, + }, + }, + scales: { + x: { + title: { + display: true, + text: "Academic Year", + font: { family: "'DM Sans', sans-serif", weight: 500 }, + }, + grid: { display: false }, + }, + y: { + title: { + display: true, + text: getMetricLabel(metric), + font: { family: "'DM Sans', sans-serif", weight: 500 }, + }, + grid: { color: "#e5dfd5" }, + }, + }, + interaction: { + intersect: false, + mode: "index", + }, + }, + }); + + // Update comparison table + updateComparisonTable(data.comparison, metric, years); } function updateComparisonTable(comparison, metric, years) { - const lastYear = years[years.length - 1]; - const prevYear = years.length > 1 ? years[years.length - 2] : null; - - // Build header with explicit year ranges - let headerHtml = 'School'; - years.forEach(year => { - headerHtml += `${year}`; + const lastYear = years[years.length - 1]; + const prevYear = years.length > 1 ? years[years.length - 2] : null; + + // Build header with explicit year ranges + let headerHtml = "School"; + years.forEach((year) => { + headerHtml += `${year}`; + }); + if (prevYear) { + headerHtml += `Δ 1yr`; + } + if (years.length > 2) { + headerHtml += `Variability`; + } + elements.tableHeader.innerHTML = headerHtml; + + // Build body + let bodyHtml = ""; + state.selectedSchools.forEach((school, index) => { + const schoolData = comparison[school.urn]; + if (!schoolData) return; + + const yearlyMap = {}; + schoolData.yearly_data.forEach((d) => { + yearlyMap[d.year] = d[metric]; + }); + + const lastValue = yearlyMap[lastYear]; + const prevValue = prevYear ? yearlyMap[prevYear] : null; + + // Calculate 1-year change + const oneYearChange = + prevValue != null && lastValue != null ? lastValue - prevValue : null; + const oneYearChangeStr = + oneYearChange !== null ? oneYearChange.toFixed(1) : "N/A"; + const oneYearClass = + oneYearChange !== null + ? oneYearChange >= 0 + ? "positive" + : "negative" + : ""; + + // Calculate variability (standard deviation) + const values = years + .map((y) => yearlyMap[y]) + .filter((v) => v != null && v !== 0); + let variabilityStr = "N/A"; + if (values.length >= 2) { + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const squaredDiffs = values.map((v) => Math.pow(v - mean, 2)); + const variance = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; + const stdDev = Math.sqrt(variance); + variabilityStr = "±" + stdDev.toFixed(1); + } + + const color = CHART_COLORS[index % CHART_COLORS.length]; + + bodyHtml += ``; + bodyHtml += `${escapeHtml(schoolData.school_info.school_name)}`; + years.forEach((year) => { + const value = yearlyMap[year]; + bodyHtml += `${value != null ? formatMetricValue(value, metric) : "-"}`; }); if (prevYear) { - headerHtml += `Δ 1yr`; + bodyHtml += `${oneYearChangeStr !== "N/A" ? (oneYearChange >= 0 ? "+" : "") + oneYearChangeStr : oneYearChangeStr}`; } if (years.length > 2) { - headerHtml += `Variability`; + bodyHtml += `${variabilityStr}`; } - elements.tableHeader.innerHTML = headerHtml; - - // Build body - let bodyHtml = ''; - state.selectedSchools.forEach((school, index) => { - const schoolData = comparison[school.urn]; - if (!schoolData) return; - - const yearlyMap = {}; - schoolData.yearly_data.forEach(d => { - yearlyMap[d.year] = d[metric]; - }); - - const lastValue = yearlyMap[lastYear]; - const prevValue = prevYear ? yearlyMap[prevYear] : null; - - // Calculate 1-year change - const oneYearChange = prevValue != null && lastValue != null ? (lastValue - prevValue) : null; - const oneYearChangeStr = oneYearChange !== null ? oneYearChange.toFixed(1) : 'N/A'; - const oneYearClass = oneYearChange !== null ? (oneYearChange >= 0 ? 'positive' : 'negative') : ''; - - // Calculate variability (standard deviation) - const values = years.map(y => yearlyMap[y]).filter(v => v != null && v !== 0); - let variabilityStr = 'N/A'; - if (values.length >= 2) { - const mean = values.reduce((a, b) => a + b, 0) / values.length; - const squaredDiffs = values.map(v => Math.pow(v - mean, 2)); - const variance = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; - const stdDev = Math.sqrt(variance); - variabilityStr = '±' + stdDev.toFixed(1); - } - - const color = CHART_COLORS[index % CHART_COLORS.length]; - - bodyHtml += ``; - bodyHtml += `${escapeHtml(schoolData.school_info.school_name)}`; - years.forEach(year => { - const value = yearlyMap[year]; - bodyHtml += `${value != null ? formatMetricValue(value, metric) : '-'}`; - }); - if (prevYear) { - bodyHtml += `${oneYearChangeStr !== 'N/A' ? (oneYearChange >= 0 ? '+' : '') + oneYearChangeStr : oneYearChangeStr}`; - } - if (years.length > 2) { - bodyHtml += `${variabilityStr}`; - } - bodyHtml += ``; - }); - elements.tableBody.innerHTML = bodyHtml; + bodyHtml += ``; + }); + elements.tableBody.innerHTML = bodyHtml; } async function openSchoolModal(urn) { - // Show loading state immediately - elements.modal.classList.add('active'); - document.body.style.overflow = 'hidden'; - elements.modalStats.innerHTML = '

Loading school data...

'; - elements.modalSchoolName.textContent = 'Loading...'; - elements.modalMeta.innerHTML = ''; - - const data = await loadSchoolDetails(urn); - if (!data) { - elements.modalStats.innerHTML = '

Unable to load school data

'; - return; - } - - state.currentSchoolData = data; - - elements.modalSchoolName.textContent = data.school_info.school_name; - elements.modalMeta.innerHTML = ` - ${escapeHtml(data.school_info.local_authority || '')} - ${escapeHtml(data.school_info.school_type || '')} + // Show loading state immediately + elements.modal.classList.add("active"); + document.body.style.overflow = "hidden"; + elements.modalStats.innerHTML = + '

Loading school data...

'; + elements.modalSchoolName.textContent = "Loading..."; + elements.modalMeta.innerHTML = ""; + + const data = await loadSchoolDetails(urn); + if (!data) { + elements.modalStats.innerHTML = + '

Unable to load school data

'; + return; + } + + state.currentSchoolData = data; + + elements.modalSchoolName.textContent = data.school_info.school_name; + elements.modalMeta.innerHTML = ` + ${escapeHtml(data.school_info.local_authority || "")} + ${escapeHtml(data.school_info.school_type || "")} Primary `; - - // Get latest year data with actual results - const sortedData = data.yearly_data.sort((a, b) => b.year - a.year); - const latest = sortedData.find(d => d.rwm_expected_pct !== null) || sortedData[0]; - - elements.modalStats.innerHTML = ` + + // Get latest year data with actual results + const sortedData = data.yearly_data.sort((a, b) => b.year - a.year); + const latest = + sortedData.find((d) => d.rwm_expected_pct !== null) || sortedData[0]; + + elements.modalStats.innerHTML = ` `; - - function getProgressClass(value) { - if (value === null || value === undefined) return ''; - return value >= 0 ? 'positive' : 'negative'; - } - - // Create chart - if (schoolDetailChart) { - schoolDetailChart.destroy(); - } - - const validData = sortedData.filter(d => d.rwm_expected_pct !== null).reverse(); - const years = validData.map(d => d.year); - const ctx = elements.schoolDetailChart.getContext('2d'); - - schoolDetailChart = new Chart(ctx, { - type: 'bar', - data: { - labels: years, - datasets: [ - { - label: 'Reading %', - data: validData.map(d => d.reading_expected_pct), - backgroundColor: '#2d7d7d', - borderRadius: 4, - }, - { - label: 'Writing %', - data: validData.map(d => d.writing_expected_pct), - backgroundColor: '#c9a227', - borderRadius: 4, - }, - { - label: 'Maths %', - data: validData.map(d => d.maths_expected_pct), - backgroundColor: '#e07256', - borderRadius: 4, - }, - ], + + function getProgressClass(value) { + if (value === null || value === undefined) return ""; + return value >= 0 ? "positive" : "negative"; + } + + // Create chart + if (schoolDetailChart) { + schoolDetailChart.destroy(); + } + + const validData = sortedData + .filter((d) => d.rwm_expected_pct !== null) + .reverse(); + const years = validData.map((d) => d.year); + const ctx = elements.schoolDetailChart.getContext("2d"); + + schoolDetailChart = new Chart(ctx, { + type: "bar", + data: { + labels: years, + datasets: [ + { + label: "Reading %", + data: validData.map((d) => d.reading_expected_pct), + backgroundColor: "#2d7d7d", + borderRadius: 4, }, - options: { - responsive: true, - maintainAspectRatio: true, - aspectRatio: 2, - plugins: { - legend: { - position: 'bottom', - labels: { - font: { family: "'DM Sans', sans-serif" }, - usePointStyle: true, - }, - }, - title: { - display: true, - text: 'KS2 Attainment Over Time (% meeting expected standard)', - font: { family: "'Playfair Display', serif", size: 16, weight: 600 }, - }, - }, - scales: { - y: { - beginAtZero: true, - max: 100, - grid: { color: '#e5dfd5' }, - }, - x: { - grid: { display: false }, - }, - }, + { + label: "Writing %", + data: validData.map((d) => d.writing_expected_pct), + backgroundColor: "#c9a227", + borderRadius: 4, }, - }); - - // Update add to compare button - const isSelected = state.selectedSchools.some(s => s.urn === data.school_info.urn); - elements.addToCompare.textContent = isSelected ? 'Remove from Compare' : 'Add to Compare'; - elements.addToCompare.dataset.urn = data.school_info.urn; + { + label: "Maths %", + data: validData.map((d) => d.maths_expected_pct), + backgroundColor: "#e07256", + borderRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2, + plugins: { + legend: { + position: "bottom", + labels: { + font: { family: "'DM Sans', sans-serif" }, + usePointStyle: true, + }, + }, + title: { + display: true, + text: "KS2 Attainment Over Time (% meeting expected standard)", + font: { family: "'Playfair Display', serif", size: 16, weight: 600 }, + }, + }, + scales: { + y: { + beginAtZero: true, + max: 100, + grid: { color: "#e5dfd5" }, + }, + x: { + grid: { display: false }, + }, + }, + }, + }); + + // Update add to compare button + const isSelected = state.selectedSchools.some( + (s) => s.urn === data.school_info.urn, + ); + elements.addToCompare.textContent = isSelected + ? "Remove from Compare" + : "Add to Compare"; + elements.addToCompare.dataset.urn = data.school_info.urn; } function closeModal() { - elements.modal.classList.remove('active'); - document.body.style.overflow = ''; - state.currentSchoolData = null; + elements.modal.classList.remove("active"); + document.body.style.overflow = ""; + state.currentSchoolData = null; } function addToComparison(school) { - if (state.selectedSchools.some(s => s.urn === school.urn)) return; - if (state.selectedSchools.length >= 5) { - alert('Maximum 5 schools can be compared at once'); - return; - } - - state.selectedSchools.push(school); - renderSelectedSchools(); + if (state.selectedSchools.some((s) => s.urn === school.urn)) return; + if (state.selectedSchools.length >= 5) { + alert("Maximum 5 schools can be compared at once"); + return; + } + + state.selectedSchools.push(school); + renderSelectedSchools(); } function removeFromComparison(urn) { - state.selectedSchools = state.selectedSchools.filter(s => s.urn !== urn); - renderSelectedSchools(); + state.selectedSchools = state.selectedSchools.filter((s) => s.urn !== urn); + renderSelectedSchools(); } function showEmptyState(container, message) { - container.innerHTML = ` + container.innerHTML = `
@@ -1060,10 +1127,10 @@ function showEmptyState(container, message) { } function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } // ============================================================================= @@ -1071,62 +1138,64 @@ function escapeHtml(text) { // ============================================================================= function setupEventListeners() { - // Navigation - document.querySelectorAll('.nav-link').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const view = link.dataset.view; - const path = view === 'dashboard' ? '/' : `/${view}`; - navigateTo(path); - }); + // Navigation + document.querySelectorAll(".nav-link").forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + const view = link.dataset.view; + const path = view === "dashboard" ? "/" : `/${view}`; + navigateTo(path); }); - - // Search and filters - let searchTimeout; - elements.schoolSearch.addEventListener('input', () => { - clearTimeout(searchTimeout); - state.pagination.page = 1; // Reset to first page on new search - searchTimeout = setTimeout(loadSchools, 300); + }); + + // Search and filters + let searchTimeout; + elements.schoolSearch.addEventListener("input", () => { + clearTimeout(searchTimeout); + state.pagination.page = 1; // Reset to first page on new search + searchTimeout = setTimeout(loadSchools, 300); + }); + + elements.localAuthorityFilter.addEventListener("change", () => { + state.pagination.page = 1; + loadSchools(); + }); + elements.typeFilter.addEventListener("change", () => { + state.pagination.page = 1; + loadSchools(); + }); + + // Location search + if (elements.locationSearchBtn) { + elements.locationSearchBtn.addEventListener("click", searchByLocation); + } + if (elements.clearLocationBtn) { + elements.clearLocationBtn.addEventListener("click", clearLocationSearch); + } + if (elements.postcodeSearch) { + elements.postcodeSearch.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + searchByLocation(); + } }); - - elements.localAuthorityFilter.addEventListener('change', () => { - state.pagination.page = 1; - loadSchools(); - }); - elements.typeFilter.addEventListener('change', () => { - state.pagination.page = 1; - loadSchools(); - }); - - // Location search - if (elements.locationSearchBtn) { - elements.locationSearchBtn.addEventListener('click', searchByLocation); - } - if (elements.clearLocationBtn) { - elements.clearLocationBtn.addEventListener('click', clearLocationSearch); - } - if (elements.postcodeSearch) { - elements.postcodeSearch.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - searchByLocation(); - } - }); - } - - // Compare search - let compareSearchTimeout; - let lastCompareSearchData = null; - - function renderCompareResults(data) { - if (!data) return; - lastCompareSearchData = data; - - const results = data.schools.filter(s => !state.selectedSchools.some(sel => sel.urn === s.urn)); - - const headerHtml = ` + } + + // Compare search + let compareSearchTimeout; + let lastCompareSearchData = null; + + function renderCompareResults(data) { + if (!data) return; + lastCompareSearchData = data; + + const results = data.schools.filter( + (s) => !state.selectedSchools.some((sel) => sel.urn === s.urn), + ); + + const headerHtml = `
- ${results.length} school${results.length !== 1 ? 's' : ''} found + ${results.length} school${results.length !== 1 ? "s" : ""} found
`; - - if (results.length === 0) { - elements.compareResults.innerHTML = headerHtml + '
No more schools to add
'; - } else { - elements.compareResults.innerHTML = headerHtml + results.slice(0, 10).map(school => ` + + if (results.length === 0) { + elements.compareResults.innerHTML = + headerHtml + + '
No more schools to add
'; + } else { + elements.compareResults.innerHTML = + headerHtml + + results + .slice(0, 10) + .map( + (school) => `
${escapeHtml(school.school_name)}
-
${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}
+
${escapeHtml(school.local_authority || "")}${school.postcode ? " • " + escapeHtml(school.postcode) : ""}
- `).join(''); - - elements.compareResults.querySelectorAll('.compare-result-item').forEach(item => { - item.addEventListener('click', () => { - const urn = parseInt(item.dataset.urn); - const school = data.schools.find(s => s.urn === urn); - if (school) { - addToComparison(school); - renderCompareResults(data); - } - }); - }); - } - - // Add close button handler - const closeBtn = elements.compareResults.querySelector('.compare-results-close'); - if (closeBtn) { - closeBtn.addEventListener('click', () => { - elements.compareResults.classList.remove('active'); - elements.compareSearch.value = ''; - }); - } - } - - elements.compareSearch.addEventListener('input', async () => { - clearTimeout(compareSearchTimeout); - const query = elements.compareSearch.value.trim(); - - if (query.length < 2) { - elements.compareResults.classList.remove('active'); - return; - } - - compareSearchTimeout = setTimeout(async () => { - const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`, { useCache: false }); - if (!data) return; - - renderCompareResults(data); - elements.compareResults.classList.add('active'); - }, 300); - }); - - elements.compareSearch.addEventListener('focus', () => { - if (elements.compareSearch.value.trim().length >= 2 && lastCompareSearchData) { - renderCompareResults(lastCompareSearchData); - elements.compareResults.classList.add('active'); - } - }); - - // Metric selector - elements.metricSelect.addEventListener('change', updateComparisonChart); - - // Rankings - elements.rankingArea.addEventListener('change', loadRankings); - elements.rankingMetric.addEventListener('change', loadRankings); - elements.rankingYear.addEventListener('change', loadRankings); - - // Modal - elements.modalClose.addEventListener('click', closeModal); - elements.modal.querySelector('.modal-backdrop').addEventListener('click', closeModal); - - elements.addToCompare.addEventListener('click', () => { - if (!state.currentSchoolData) return; - - const urn = state.currentSchoolData.school_info.urn; - const isSelected = state.selectedSchools.some(s => s.urn === urn); - - if (isSelected) { - removeFromComparison(urn); - elements.addToCompare.textContent = 'Add to Compare'; - } else { - addToComparison(state.currentSchoolData.school_info); - elements.addToCompare.textContent = 'Remove from Compare'; - } - }); - - // Keyboard - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - if (elements.compareResults.classList.contains('active')) { - elements.compareResults.classList.remove('active'); - elements.compareSearch.value = ''; - return; + `, + ) + .join(""); + + elements.compareResults + .querySelectorAll(".compare-result-item") + .forEach((item) => { + item.addEventListener("click", () => { + const urn = parseInt(item.dataset.urn); + const school = data.schools.find((s) => s.urn === urn); + if (school) { + addToComparison(school); + renderCompareResults(data); } - closeModal(); - } - }); + }); + }); + } + + // Add close button handler + const closeBtn = elements.compareResults.querySelector( + ".compare-results-close", + ); + if (closeBtn) { + closeBtn.addEventListener("click", () => { + elements.compareResults.classList.remove("active"); + elements.compareSearch.value = ""; + }); + } + } + + elements.compareSearch.addEventListener("input", async () => { + clearTimeout(compareSearchTimeout); + const query = elements.compareSearch.value.trim(); + + if (query.length < 2) { + elements.compareResults.classList.remove("active"); + return; + } + + compareSearchTimeout = setTimeout(async () => { + const data = await fetchAPI( + `/api/schools?search=${encodeURIComponent(query)}`, + { useCache: false }, + ); + if (!data) return; + + renderCompareResults(data); + elements.compareResults.classList.add("active"); + }, 300); + }); + + elements.compareSearch.addEventListener("focus", () => { + if ( + elements.compareSearch.value.trim().length >= 2 && + lastCompareSearchData + ) { + renderCompareResults(lastCompareSearchData); + elements.compareResults.classList.add("active"); + } + }); + + // Metric selector + elements.metricSelect.addEventListener("change", updateComparisonChart); + + // Rankings + elements.rankingArea.addEventListener("change", loadRankings); + elements.rankingMetric.addEventListener("change", loadRankings); + elements.rankingYear.addEventListener("change", loadRankings); + + // Modal + elements.modalClose.addEventListener("click", closeModal); + elements.modal + .querySelector(".modal-backdrop") + .addEventListener("click", closeModal); + + elements.addToCompare.addEventListener("click", () => { + if (!state.currentSchoolData) return; + + const urn = state.currentSchoolData.school_info.urn; + const isSelected = state.selectedSchools.some((s) => s.urn === urn); + + if (isSelected) { + removeFromComparison(urn); + elements.addToCompare.textContent = "Add to Compare"; + } else { + addToComparison(state.currentSchoolData.school_info); + elements.addToCompare.textContent = "Remove from Compare"; + } + }); + + // Keyboard + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (elements.compareResults.classList.contains("active")) { + elements.compareResults.classList.remove("active"); + elements.compareSearch.value = ""; + return; + } + closeModal(); + } + }); }