/**
* SchoolCompare.co.uk - Frontend Application
* Interactive UK Primary School Performance Comparison
*/
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
searchMode: "name", // "name" or "location"
locationSearch: {
active: false,
postcode: null,
radius: 5,
},
loading: {
schools: false,
filters: false,
rankings: false,
comparison: false,
modal: false,
},
};
// Charts
let comparisonChart = null;
let schoolDetailChart = null;
// Chart colors
const CHART_COLORS = [
"#e07256", // coral
"#2d7d7d", // teal
"#c9a227", // gold
"#7b68a6", // purple
"#3498db", // blue
"#27ae60", // green
"#e74c3c", // red
"#9b59b6", // violet
];
// Helper to get chart aspect ratio based on screen size
function getChartAspectRatio() {
return window.innerWidth <= 768 ? 1.2 : 2;
}
// =============================================================================
// DOM ELEMENTS
// =============================================================================
const elements = {
// Search mode toggle
searchModeToggle: document.querySelector(".search-mode-toggle"),
searchModeBtns: document.querySelectorAll(".search-mode-btn"),
nameSearchPanel: document.getElementById("name-search-panel"),
locationSearchPanel: document.getElementById("location-search-panel"),
// Name search
schoolSearch: document.getElementById("school-search"),
localAuthorityFilter: document.getElementById("local-authority-filter"),
typeFilter: document.getElementById("type-filter"),
// Location search
postcodeSearch: document.getElementById("postcode-search"),
radiusSelect: document.getElementById("radius-select"),
locationSearchBtn: document.getElementById("location-search-btn"),
typeFilterLocation: document.getElementById("type-filter-location"),
// Schools grid
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"),
};
// =============================================================================
// API & CACHING
// =============================================================================
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);
}
// 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] = 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 =
'
';
}
break;
}
}
function renderLoadingSkeleton(count, type = "card") {
if (type === "ranking") {
return Array(count)
.fill(0)
.map(
() => `
`,
)
.join("");
}
return Array(count)
.fill(0)
.map(
() => `
`,
)
.join("");
}
// =============================================================================
// ROUTING
// =============================================================================
const routes = {
"/": "dashboard",
"/compare": "compare",
"/rankings": "rankings",
};
function navigateTo(path) {
// 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");
}
}
// =============================================================================
// INITIALIZATION
// =============================================================================
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);
}
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 school type filter (both name and location panels)
data.school_types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
elements.typeFilter.appendChild(option);
// Also add to location panel's type filter
const optionLocation = document.createElement("option");
optionLocation.value = type;
optionLocation.textContent = type;
elements.typeFilterLocation.appendChild(optionLocation);
});
// 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);
});
}
// =============================================================================
// DATA LOADING
// =============================================================================
async function loadSchools() {
const params = new URLSearchParams();
if (state.searchMode === "name") {
// Name search mode
const search = elements.schoolSearch.value.trim();
const localAuthority = elements.localAuthorityFilter.value;
const type = elements.typeFilter.value;
// If no search query (or less than 2 chars) and no filters, show featured schools
if (search.length < 2 && !localAuthority && !type) {
await loadFeaturedSchools();
return;
}
if (search.length >= 2) params.append("search", search);
if (localAuthority) params.append("local_authority", localAuthority);
if (type) params.append("school_type", type);
} else {
// Location search mode
const { active: locationActive, postcode, radius } = state.locationSearch;
const type = elements.typeFilterLocation.value;
// If no location search active, show featured schools
if (!locationActive) {
await loadFeaturedSchools();
return;
}
params.append("postcode", postcode);
params.append("radius", radius);
if (type) params.append("school_type", type);
}
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);
}
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 = `
Showing schools within ${searchLocation.radius} miles of ${searchLocation.postcode.toUpperCase()}
`;
// 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;
// Load schools with location filter
await loadSchools();
}
function renderFeaturedSchools(schools) {
elements.schoolsGrid.innerHTML = `
${schools
.map(
(school) => `
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || "")}
${escapeHtml(school.school_type || "")}
${escapeHtml(school.address || "")}
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
RWM Expected
${school.total_pupils || "-"}
Pupils
`,
)
.join("")}
`;
// 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;
}
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;
}
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);
}
// =============================================================================
// METRIC HELPERS
// =============================================================================
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;
}
}
// 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);
}
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
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 `
${escapeHtml(school.school_name)}${distanceBadge}
${escapeHtml(school.local_authority || "")}
${escapeHtml(school.school_type || "")}
${escapeHtml(school.address || "")}
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
RWM Expected
${school.total_pupils || "-"}
Pupils
`;
})
.join("");
// Add pagination info
if (state.pagination.totalPages > 1) {
html += `
`;
}
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();
if (state.searchMode === "name") {
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);
} else {
const { postcode, radius } = state.locationSearch;
if (postcode) {
params.append("postcode", postcode);
params.append("radius", radius);
}
const type = elements.typeFilterLocation.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 `
${index + 1}
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || "")}
${formatMetricValue(value, metric)}
${getMetricLabel(metric, true)}
`;
})
.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 = `
Search and add schools to compare
`;
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);
});
});
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,
});
});
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: getChartAspectRatio(),
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",
},
},
});
// 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} | `;
});
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) {
bodyHtml += `${oneYearChangeStr !== "N/A" ? (oneYearChange >= 0 ? "+" : "") + oneYearChangeStr : oneYearChangeStr} | `;
}
if (years.length > 2) {
bodyHtml += `${variabilityStr} | `;
}
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 =
'';
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 = `
KS2 Results (${latest.year})
${formatMetricValue(latest.rwm_expected_pct, "rwm_expected_pct")}
RWM Expected
${formatMetricValue(latest.rwm_high_pct, "rwm_high_pct")}
RWM Higher
${formatMetricValue(latest.gps_expected_pct, "gps_expected_pct")}
GPS Expected
${formatMetricValue(latest.science_expected_pct, "science_expected_pct")}
Science Expected
Progress Scores
${formatMetricValue(latest.reading_progress, "reading_progress")}
Reading
${formatMetricValue(latest.writing_progress, "writing_progress")}
Writing
${formatMetricValue(latest.maths_progress, "maths_progress")}
Maths
School Context
${latest.total_pupils || "-"}
Total Pupils
${formatMetricValue(latest.disadvantaged_pct, "disadvantaged_pct")}
% Disadvantaged
${formatMetricValue(latest.eal_pct, "eal_pct")}
% EAL
${formatMetricValue(latest.sen_support_pct, "sen_support_pct")}
% SEN Support
`;
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,
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: getChartAspectRatio(),
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;
}
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();
}
function removeFromComparison(urn) {
state.selectedSchools = state.selectedSchools.filter((s) => s.urn !== urn);
renderSelectedSchools();
}
function showEmptyState(container, message) {
container.innerHTML = `
`;
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// =============================================================================
// EVENT LISTENERS
// =============================================================================
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);
});
});
// Search mode toggle
elements.searchModeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
if (mode === state.searchMode) return;
// Update state
state.searchMode = mode;
state.pagination.page = 1;
// Update toggle buttons
elements.searchModeBtns.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// Update panels
elements.nameSearchPanel.classList.toggle("active", mode === "name");
elements.locationSearchPanel.classList.toggle(
"active",
mode === "location",
);
// Clear the inactive mode's state
if (mode === "name") {
// Clear location search state
state.locationSearch = { active: false, postcode: null, radius: 5 };
elements.postcodeSearch.value = "";
elements.radiusSelect.value = "5";
elements.typeFilterLocation.value = "";
updateLocationInfoBanner(null);
} else {
// Clear name search state
elements.schoolSearch.value = "";
elements.localAuthorityFilter.value = "";
elements.typeFilter.value = "";
}
// Reload schools (will show featured if no active search)
loadSchools();
});
});
// Name 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.postcodeSearch) {
elements.postcodeSearch.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
searchByLocation();
}
});
}
if (elements.typeFilterLocation) {
elements.typeFilterLocation.addEventListener("change", () => {
if (state.locationSearch.active) {
state.pagination.page = 1;
loadSchools();
}
});
}
// 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 = `
`;
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) : ""}
`,
)
.join("");
elements.compareResults
.querySelectorAll(".compare-result-item")
.forEach((item) => {
item.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent click-outside handler from closing results
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");
}
});
// Close compare results when clicking outside
document.addEventListener("click", (e) => {
if (!elements.compareResults.classList.contains("active")) return;
// Don't close if clicking inside the search input or results
if (
elements.compareSearch.contains(e.target) ||
elements.compareResults.contains(e.target)
) {
return;
}
// Close the results
elements.compareResults.classList.remove("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();
}
});
}