Files
school_compare/frontend/app.js

1234 lines
44 KiB
JavaScript
Raw Normal View History

2026-01-06 13:52:00 +00:00
/**
* SchoolCompare.co.uk - Frontend Application
* Interactive UK Primary School Performance Comparison
2026-01-06 13:52:00 +00:00
*/
const API_BASE = '';
2026-01-06 16:30:32 +00:00
// =============================================================================
// 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
2026-01-06 16:59:25 +00:00
locationSearch: {
active: false,
postcode: null,
radius: 5,
},
2026-01-06 16:30:32 +00:00
loading: {
schools: false,
filters: false,
rankings: false,
comparison: false,
modal: false,
},
};
// Charts
2026-01-06 13:52:00 +00:00
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
];
2026-01-06 16:30:32 +00:00
// =============================================================================
// DOM ELEMENTS
// =============================================================================
2026-01-06 13:52:00 +00:00
const elements = {
schoolSearch: document.getElementById('school-search'),
localAuthorityFilter: document.getElementById('local-authority-filter'),
typeFilter: document.getElementById('type-filter'),
2026-01-06 16:59:25 +00:00
postcodeSearch: document.getElementById('postcode-search'),
radiusSelect: document.getElementById('radius-select'),
locationSearchBtn: document.getElementById('location-search-btn'),
clearLocationBtn: document.getElementById('clear-location-btn'),
2026-01-06 13:52:00 +00:00
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'),
2026-01-06 16:59:25 +00:00
rankingArea: document.getElementById('ranking-area'),
2026-01-06 13:52:00 +00:00
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'),
};
2026-01-06 16:30:32 +00:00
// =============================================================================
// API & CACHING
// =============================================================================
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
const apiCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
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);
}
2026-01-06 13:52:00 +00:00
try {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
2026-01-06 16:30:32 +00:00
const data = await response.json();
// Cache the response
if (useCache) {
apiCache.set(cacheKey, { data, timestamp: Date.now() });
}
return data;
2026-01-06 13:52:00 +00:00
} catch (error) {
console.error(`API Error (${endpoint}):`, error);
return null;
2026-01-06 16:30:32 +00:00
} finally {
if (showLoading) {
state.loading[showLoading] = false;
updateLoadingUI(showLoading);
}
2026-01-06 13:52:00 +00:00
}
}
2026-01-06 16:30:32 +00:00
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 = '<div class="loading"><div class="loading-spinner"></div><p>Loading school data...</p></div>';
}
break;
}
}
function renderLoadingSkeleton(count, type = 'card') {
if (type === 'ranking') {
return Array(count).fill(0).map(() => `
<div class="ranking-item skeleton">
<div class="skeleton-circle"></div>
<div class="skeleton-content">
<div class="skeleton-line" style="width: 60%"></div>
<div class="skeleton-line short" style="width: 30%"></div>
</div>
<div class="skeleton-score"></div>
</div>
`).join('');
}
return Array(count).fill(0).map(() => `
<div class="school-card skeleton">
<div class="skeleton-line" style="width: 70%"></div>
<div class="skeleton-line short" style="width: 40%"></div>
<div class="skeleton-line" style="width: 90%"></div>
</div>
`).join('');
}
2026-01-06 16:59:25 +00:00
// =============================================================================
// 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');
}
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// 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();
2026-01-06 16:59:25 +00:00
// Handle initial route
handleRoute();
// Handle browser back/forward
window.addEventListener('popstate', handleRoute);
2026-01-06 16:30:32 +00:00
}
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
function populateFilters(data) {
2026-01-06 16:59:25 +00:00
// Populate local authority filter (dashboard)
2026-01-06 16:17:00 +00:00
data.local_authorities.forEach(la => {
const option = document.createElement('option');
option.value = la;
option.textContent = la;
elements.localAuthorityFilter.appendChild(option);
});
2026-01-06 13:52:00 +00:00
// Populate school type filter
data.school_types.forEach(type => {
const option = document.createElement('option');
option.value = type;
option.textContent = type;
elements.typeFilter.appendChild(option);
});
2026-01-06 16:30:32 +00:00
2026-01-06 16:59:25 +00:00
// Populate ranking area dropdown
data.local_authorities.forEach(la => {
const option = document.createElement('option');
option.value = la;
option.textContent = la;
elements.rankingArea.appendChild(option);
});
2026-01-06 16:30:32 +00:00
// 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);
});
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// DATA LOADING
// =============================================================================
2026-01-06 13:52:00 +00:00
async function loadSchools() {
const search = elements.schoolSearch.value.trim();
const localAuthority = elements.localAuthorityFilter.value;
const type = elements.typeFilter.value;
2026-01-06 16:59:25 +00:00
const { active: locationActive, postcode, radius } = state.locationSearch;
2026-01-06 16:30:32 +00:00
2026-01-06 16:59:25 +00:00
// 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) {
2026-01-06 16:30:32 +00:00
await loadFeaturedSchools();
return;
}
const params = new URLSearchParams();
if (search.length >= 2) params.append('search', search);
if (localAuthority) params.append('local_authority', localAuthority);
2026-01-06 13:52:00 +00:00
if (type) params.append('school_type', type);
2026-01-06 16:59:25 +00:00
// Add location search params
if (locationActive && postcode) {
params.append('postcode', postcode);
params.append('radius', radius);
}
2026-01-06 16:30:32 +00:00
params.append('page', state.pagination.page);
params.append('page_size', state.pagination.pageSize);
2026-01-06 13:52:00 +00:00
const queryString = params.toString();
2026-01-06 16:30:32 +00:00
const endpoint = `/api/schools?${queryString}`;
// Don't cache search results (they change based on input)
const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'schools' });
2026-01-06 13:52:00 +00:00
if (!data) {
showEmptyState(elements.schoolsGrid, 'Unable to load schools');
return;
}
2026-01-06 16:30:32 +00:00
state.schools = data.schools;
state.pagination.total = data.total;
state.pagination.totalPages = data.total_pages;
state.isShowingFeatured = false;
2026-01-06 16:59:25 +00:00
// Show location info banner if location search is active
updateLocationInfoBanner(data.search_location);
2026-01-06 16:30:32 +00:00
renderSchools(state.schools);
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
async function loadFeaturedSchools() {
2026-01-06 16:59:25 +00:00
// Clear location info when showing featured
updateLocationInfoBanner(null);
2026-01-06 16:30:32 +00:00
// 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);
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:59:25 +00:00
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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
<span>Showing schools within ${searchLocation.radius} miles of <strong>${searchLocation.postcode.toUpperCase()}</strong></span>
`;
// 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();
}
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();
}
2026-01-06 16:30:32 +00:00
function renderFeaturedSchools(schools) {
elements.schoolsGrid.innerHTML = `
<div class="featured-header">
<h3>Featured Schools</h3>
<p>Start typing to search ${state.filters?.local_authorities?.length || ''} schools across England</p>
</div>
${schools.map(school => `
<div class="school-card featured" data-urn="${school.urn}">
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
<div class="school-meta">
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
</div>
<div class="school-address">${escapeHtml(school.address || '')}</div>
<div class="school-stats">
<div class="stat">
<div class="stat-value">Primary</div>
<div class="stat-label">Phase</div>
</div>
<div class="stat">
<div class="stat-value">KS2</div>
<div class="stat-label">Data</div>
</div>
</div>
</div>
`).join('')}
`;
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
// 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' });
2026-01-06 13:52:00 +00:00
return data;
}
2026-01-06 16:30:32 +00:00
async function loadComparison() {
if (state.selectedSchools.length === 0) return null;
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
const urns = state.selectedSchools.map(s => s.urn).join(',');
const data = await fetchAPI(`/api/compare?urns=${urns}`, { useCache: false, showLoading: 'comparison' });
return data;
2026-01-06 13:52:00 +00:00
}
async function loadRankings() {
2026-01-06 16:59:25 +00:00
const area = elements.rankingArea.value;
2026-01-06 13:52:00 +00:00
const metric = elements.rankingMetric.value;
const year = elements.rankingYear.value;
let endpoint = `/api/rankings?metric=${metric}&limit=20`;
if (year) endpoint += `&year=${year}`;
2026-01-06 16:59:25 +00:00
if (area) endpoint += `&local_authority=${encodeURIComponent(area)}`;
2026-01-06 13:52:00 +00:00
2026-01-06 16:59:25 +00:00
const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'rankings' });
2026-01-06 16:30:32 +00:00
2026-01-06 13:52:00 +00:00
if (!data) {
showEmptyState(elements.rankingsList, 'Unable to load rankings');
return;
}
renderRankings(data.rankings, metric);
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// 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
// =============================================================================
2026-01-06 13:52:00 +00:00
function renderSchools(schools) {
if (schools.length === 0) {
2026-01-06 16:59:25 +00:00
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);
2026-01-06 13:52:00 +00:00
return;
}
2026-01-06 16:59:25 +00:00
let html = schools.map(school => {
const distanceBadge = school.distance !== undefined && school.distance !== null
? `<span class="distance-badge">${school.distance.toFixed(1)} mi</span>`
: '';
return `
<div class="school-card" data-urn="${school.urn}">
<h3 class="school-name">${escapeHtml(school.school_name)}${distanceBadge}</h3>
<div class="school-meta">
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
2026-01-06 13:52:00 +00:00
</div>
2026-01-06 16:59:25 +00:00
<div class="school-address">${escapeHtml(school.address || '')}</div>
<div class="school-stats">
<div class="stat">
<div class="stat-value">Primary</div>
<div class="stat-label">Phase</div>
</div>
<div class="stat">
<div class="stat-value">KS2</div>
<div class="stat-label">Data</div>
</div>
2026-01-06 13:52:00 +00:00
</div>
</div>
2026-01-06 16:59:25 +00:00
`;
}).join('');
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
// Add pagination info
if (state.pagination.totalPages > 1) {
html += `
<div class="pagination-info">
<span>Showing ${schools.length} of ${state.pagination.total} schools</span>
${state.pagination.page < state.pagination.totalPages ?
`<button class="btn-load-more" onclick="loadMoreSchools()">Load More</button>` : ''}
</div>
`;
}
elements.schoolsGrid.innerHTML = html;
2026-01-06 13:52:00 +00:00
// Add click handlers
elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => {
card.addEventListener('click', () => {
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});
});
}
2026-01-06 16:30:32 +00:00
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);
}
}
2026-01-06 13:52:00 +00:00
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 `
<div class="ranking-item" data-urn="${school.urn}">
<div class="ranking-position ${index < 3 ? 'top-3' : ''}">${index + 1}</div>
<div class="ranking-info">
<div class="ranking-name">${escapeHtml(school.school_name)}</div>
<div class="ranking-location">${escapeHtml(school.local_authority || '')}</div>
</div>
<div class="ranking-score">
2026-01-06 16:30:32 +00:00
<div class="ranking-score-value">${formatMetricValue(value, metric)}</div>
<div class="ranking-score-label">${getMetricLabel(metric, true)}</div>
2026-01-06 13:52:00 +00:00
</div>
</div>
`;
}).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() {
2026-01-06 16:30:32 +00:00
if (state.selectedSchools.length === 0) {
2026-01-06 13:52:00 +00:00
elements.selectedSchools.innerHTML = `
<div class="empty-selection">
<div class="empty-icon">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="6" y="10" width="36" height="28" rx="2"/>
<path d="M6 18h36"/>
<circle cx="14" cy="14" r="2" fill="currentColor"/>
<circle cx="22" cy="14" r="2" fill="currentColor"/>
</svg>
</div>
<p>Search and add schools to compare</p>
</div>
`;
elements.chartsSection.style.display = 'none';
return;
}
2026-01-06 16:30:32 +00:00
elements.selectedSchools.innerHTML = state.selectedSchools.map((school, index) => `
2026-01-06 13:52:00 +00:00
<div class="selected-school-tag" style="border-left: 3px solid ${CHART_COLORS[index % CHART_COLORS.length]}">
<span>${escapeHtml(school.school_name)}</span>
<button class="remove" data-urn="${school.urn}" title="Remove">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4L4 12M4 4l8 8"/>
</svg>
</button>
</div>
`).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() {
2026-01-06 16:30:32 +00:00
if (state.selectedSchools.length === 0) return;
2026-01-06 13:52:00 +00:00
const data = await loadComparison();
if (!data) return;
const metric = elements.metricSelect.value;
2026-01-06 16:30:32 +00:00
// Prepare chart data
2026-01-06 13:52:00 +00:00
const datasets = [];
const allYears = new Set();
2026-01-06 16:30:32 +00:00
state.selectedSchools.forEach((school, index) => {
2026-01-06 13:52:00 +00:00
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: 2,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'DM Sans', sans-serif", size: 12 },
padding: 20,
usePointStyle: true,
},
},
title: {
display: true,
2026-01-06 16:30:32 +00:00
text: getMetricLabel(metric),
2026-01-06 13:52:00 +00:00
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,
2026-01-06 16:30:32 +00:00
text: getMetricLabel(metric),
2026-01-06 13:52:00 +00:00
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) {
2026-01-06 15:37:07 +00:00
const lastYear = years[years.length - 1];
const prevYear = years.length > 1 ? years[years.length - 2] : null;
// Build header with explicit year ranges
2026-01-06 13:52:00 +00:00
let headerHtml = '<th>School</th>';
years.forEach(year => {
headerHtml += `<th>${year}</th>`;
});
if (prevYear) {
2026-01-06 15:37:07 +00:00
headerHtml += `<th title="Change from ${prevYear} to ${lastYear}">Δ 1yr</th>`;
}
if (years.length > 2) {
headerHtml += `<th title="Standard deviation of scores (lower = more consistent)">Variability</th>`;
2026-01-06 15:37:07 +00:00
}
2026-01-06 13:52:00 +00:00
elements.tableHeader.innerHTML = headerHtml;
2026-01-06 16:30:32 +00:00
// Build body
2026-01-06 13:52:00 +00:00
let bodyHtml = '';
2026-01-06 16:30:32 +00:00
state.selectedSchools.forEach((school, index) => {
2026-01-06 13:52:00 +00:00
const schoolData = comparison[school.urn];
if (!schoolData) return;
const yearlyMap = {};
schoolData.yearly_data.forEach(d => {
yearlyMap[d.year] = d[metric];
});
2026-01-06 15:37:07 +00:00
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') : '';
2026-01-06 16:30:32 +00:00
// 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);
}
2026-01-06 15:37:07 +00:00
2026-01-06 13:52:00 +00:00
const color = CHART_COLORS[index % CHART_COLORS.length];
bodyHtml += `<tr>`;
bodyHtml += `<td><strong style="border-left: 3px solid ${color}; padding-left: 8px;">${escapeHtml(schoolData.school_info.school_name)}</strong></td>`;
years.forEach(year => {
const value = yearlyMap[year];
2026-01-06 15:37:07 +00:00
bodyHtml += `<td>${value != null ? formatMetricValue(value, metric) : '-'}</td>`;
2026-01-06 13:52:00 +00:00
});
if (prevYear) {
2026-01-06 15:37:07 +00:00
bodyHtml += `<td class="${oneYearClass}">${oneYearChangeStr !== 'N/A' ? (oneYearChange >= 0 ? '+' : '') + oneYearChangeStr : oneYearChangeStr}</td>`;
}
if (years.length > 2) {
bodyHtml += `<td>${variabilityStr}</td>`;
2026-01-06 15:37:07 +00:00
}
2026-01-06 13:52:00 +00:00
bodyHtml += `</tr>`;
});
elements.tableBody.innerHTML = bodyHtml;
}
async function openSchoolModal(urn) {
2026-01-06 16:30:32 +00:00
// Show loading state immediately
elements.modal.classList.add('active');
document.body.style.overflow = 'hidden';
elements.modalStats.innerHTML = '<div class="loading"><div class="loading-spinner"></div><p>Loading school data...</p></div>';
elements.modalSchoolName.textContent = 'Loading...';
elements.modalMeta.innerHTML = '';
2026-01-06 13:52:00 +00:00
const data = await loadSchoolDetails(urn);
2026-01-06 16:30:32 +00:00
if (!data) {
elements.modalStats.innerHTML = '<div class="empty-state"><p>Unable to load school data</p></div>';
return;
}
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
state.currentSchoolData = data;
2026-01-06 13:52:00 +00:00
elements.modalSchoolName.textContent = data.school_info.school_name;
elements.modalMeta.innerHTML = `
<span class="school-tag">${escapeHtml(data.school_info.local_authority || '')}</span>
<span class="school-tag type">${escapeHtml(data.school_info.school_type || '')}</span>
<span class="school-tag">Primary</span>
`;
2026-01-06 16:30:32 +00:00
// Get latest year data with actual results
2026-01-06 13:52:00 +00:00
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 = `
<div class="modal-stats-section">
<h4>KS2 Results (${latest.year})</h4>
<div class="modal-stats-grid">
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.rwm_expected_pct, 'rwm_expected_pct')}</div>
<div class="modal-stat-label">RWM Expected</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.rwm_high_pct, 'rwm_high_pct')}</div>
<div class="modal-stat-label">RWM Higher</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.gps_expected_pct, 'gps_expected_pct')}</div>
<div class="modal-stat-label">GPS Expected</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.science_expected_pct, 'science_expected_pct')}</div>
<div class="modal-stat-label">Science Expected</div>
</div>
</div>
</div>
<div class="modal-stats-section">
<h4>Progress Scores</h4>
<div class="modal-stats-grid">
<div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.reading_progress)}">${formatMetricValue(latest.reading_progress, 'reading_progress')}</div>
<div class="modal-stat-label">Reading</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.writing_progress)}">${formatMetricValue(latest.writing_progress, 'writing_progress')}</div>
<div class="modal-stat-label">Writing</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.maths_progress)}">${formatMetricValue(latest.maths_progress, 'maths_progress')}</div>
<div class="modal-stat-label">Maths</div>
</div>
</div>
</div>
<div class="modal-stats-section">
<h4>School Context</h4>
<div class="modal-stats-grid">
<div class="modal-stat">
<div class="modal-stat-value">${latest.total_pupils || '-'}</div>
<div class="modal-stat-label">Total Pupils</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.disadvantaged_pct, 'disadvantaged_pct')}</div>
<div class="modal-stat-label">% Disadvantaged</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.eal_pct, 'eal_pct')}</div>
<div class="modal-stat-label">% EAL</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.sen_support_pct, 'sen_support_pct')}</div>
<div class="modal-stat-label">% SEN Support</div>
</div>
</div>
</div>
`;
function getProgressClass(value) {
if (value === null || value === undefined) return '';
return value >= 0 ? 'positive' : 'negative';
}
2026-01-06 16:30:32 +00:00
// Create chart
2026-01-06 13:52:00 +00:00
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: 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
2026-01-06 16:30:32 +00:00
const isSelected = state.selectedSchools.some(s => s.urn === data.school_info.urn);
2026-01-06 13:52:00 +00:00
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 = '';
2026-01-06 16:30:32 +00:00
state.currentSchoolData = null;
2026-01-06 13:52:00 +00:00
}
function addToComparison(school) {
2026-01-06 16:30:32 +00:00
if (state.selectedSchools.some(s => s.urn === school.urn)) return;
if (state.selectedSchools.length >= 5) {
2026-01-06 13:52:00 +00:00
alert('Maximum 5 schools can be compared at once');
return;
}
2026-01-06 16:30:32 +00:00
state.selectedSchools.push(school);
2026-01-06 13:52:00 +00:00
renderSelectedSchools();
}
function removeFromComparison(urn) {
2026-01-06 16:30:32 +00:00
state.selectedSchools = state.selectedSchools.filter(s => s.urn !== urn);
2026-01-06 13:52:00 +00:00
renderSelectedSchools();
}
function showEmptyState(container, message) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="24" cy="24" r="20"/>
<path d="M16 20h16M16 28h10"/>
</svg>
<p>${escapeHtml(message)}</p>
</div>
`;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// EVENT LISTENERS
// =============================================================================
2026-01-06 13:52:00 +00:00
function setupEventListeners() {
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
2026-01-06 16:59:25 +00:00
const path = view === 'dashboard' ? '/' : `/${view}`;
navigateTo(path);
2026-01-06 13:52:00 +00:00
});
});
// Search and filters
let searchTimeout;
elements.schoolSearch.addEventListener('input', () => {
clearTimeout(searchTimeout);
2026-01-06 16:30:32 +00:00
state.pagination.page = 1; // Reset to first page on new search
2026-01-06 13:52:00 +00:00
searchTimeout = setTimeout(loadSchools, 300);
});
2026-01-06 16:30:32 +00:00
elements.localAuthorityFilter.addEventListener('change', () => {
state.pagination.page = 1;
loadSchools();
});
elements.typeFilter.addEventListener('change', () => {
state.pagination.page = 1;
loadSchools();
});
2026-01-06 13:52:00 +00:00
2026-01-06 16:59:25 +00:00
// 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();
}
});
}
2026-01-06 13:52:00 +00:00
// Compare search
let compareSearchTimeout;
2026-01-06 15:58:15 +00:00
let lastCompareSearchData = null;
function renderCompareResults(data) {
if (!data) return;
lastCompareSearchData = data;
2026-01-06 16:30:32 +00:00
const results = data.schools.filter(s => !state.selectedSchools.some(sel => sel.urn === s.urn));
2026-01-06 15:58:15 +00:00
const headerHtml = `
<div class="compare-results-header">
<span>${results.length} school${results.length !== 1 ? 's' : ''} found</span>
<button class="compare-results-close" title="Close (Esc)">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4L4 12M4 4l8 8"/>
</svg>
</button>
</div>
`;
if (results.length === 0) {
elements.compareResults.innerHTML = headerHtml + '<div class="compare-result-item"><span class="name">No more schools to add</span></div>';
} else {
elements.compareResults.innerHTML = headerHtml + results.slice(0, 10).map(school => `
<div class="compare-result-item" data-urn="${school.urn}" data-name="${escapeHtml(school.school_name)}">
<div class="name">${escapeHtml(school.school_name)}</div>
<div class="location">${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}</div>
</div>
`).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 = '';
});
}
}
2026-01-06 13:52:00 +00:00
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 () => {
2026-01-06 16:30:32 +00:00
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`, { useCache: false });
2026-01-06 13:52:00 +00:00
if (!data) return;
2026-01-06 15:58:15 +00:00
renderCompareResults(data);
2026-01-06 13:52:00 +00:00
elements.compareResults.classList.add('active');
}, 300);
});
elements.compareSearch.addEventListener('focus', () => {
2026-01-06 15:58:15 +00:00
if (elements.compareSearch.value.trim().length >= 2 && lastCompareSearchData) {
renderCompareResults(lastCompareSearchData);
2026-01-06 13:52:00 +00:00
elements.compareResults.classList.add('active');
}
});
// Metric selector
elements.metricSelect.addEventListener('change', updateComparisonChart);
// Rankings
2026-01-06 16:59:25 +00:00
elements.rankingArea.addEventListener('change', loadRankings);
2026-01-06 13:52:00 +00:00
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', () => {
2026-01-06 16:30:32 +00:00
if (!state.currentSchoolData) return;
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
const urn = state.currentSchoolData.school_info.urn;
const isSelected = state.selectedSchools.some(s => s.urn === urn);
2026-01-06 13:52:00 +00:00
if (isSelected) {
removeFromComparison(urn);
elements.addToCompare.textContent = 'Add to Compare';
} else {
2026-01-06 16:30:32 +00:00
addToComparison(state.currentSchoolData.school_info);
2026-01-06 13:52:00 +00:00
elements.addToCompare.textContent = 'Remove from Compare';
}
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
2026-01-06 15:58:15 +00:00
if (elements.compareResults.classList.contains('active')) {
elements.compareResults.classList.remove('active');
elements.compareSearch.value = '';
return;
}
2026-01-06 13:52:00 +00:00
closeModal();
}
});
}