Refactoring and bug fixes
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
This commit is contained in:
584
frontend/app.js
584
frontend/app.js
@@ -5,12 +5,30 @@
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
// State
|
||||
let allSchools = [];
|
||||
let selectedSchools = [];
|
||||
// =============================================================================
|
||||
// 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
|
||||
loading: {
|
||||
schools: false,
|
||||
filters: false,
|
||||
rankings: false,
|
||||
comparison: false,
|
||||
modal: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Charts
|
||||
let comparisonChart = null;
|
||||
let schoolDetailChart = null;
|
||||
let currentSchoolData = null;
|
||||
|
||||
// Chart colors
|
||||
const CHART_COLORS = [
|
||||
@@ -24,7 +42,10 @@ const CHART_COLORS = [
|
||||
'#9b59b6', // violet
|
||||
];
|
||||
|
||||
// DOM Elements
|
||||
// =============================================================================
|
||||
// DOM ELEMENTS
|
||||
// =============================================================================
|
||||
|
||||
const elements = {
|
||||
schoolSearch: document.getElementById('school-search'),
|
||||
localAuthorityFilter: document.getElementById('local-authority-filter'),
|
||||
@@ -51,33 +72,129 @@ const elements = {
|
||||
addToCompare: document.getElementById('add-to-compare'),
|
||||
};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
// =============================================================================
|
||||
// API & CACHING
|
||||
// =============================================================================
|
||||
|
||||
async function init() {
|
||||
await loadFilters();
|
||||
await loadSchools();
|
||||
await loadRankingYears();
|
||||
await loadRankings();
|
||||
setupEventListeners();
|
||||
}
|
||||
const apiCache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// API Functions
|
||||
async function fetchAPI(endpoint) {
|
||||
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}`);
|
||||
return await response.json();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
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('');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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();
|
||||
}
|
||||
|
||||
function populateFilters(data) {
|
||||
// Populate local authority filter
|
||||
data.local_authorities.forEach(la => {
|
||||
const option = document.createElement('option');
|
||||
@@ -93,50 +210,8 @@ async function loadFilters() {
|
||||
option.textContent = type;
|
||||
elements.typeFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSchools() {
|
||||
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);
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/schools${queryString ? '?' + queryString : ''}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
if (!data) {
|
||||
showEmptyState(elements.schoolsGrid, 'Unable to load schools');
|
||||
return;
|
||||
}
|
||||
|
||||
allSchools = data.schools;
|
||||
renderSchools(allSchools);
|
||||
}
|
||||
|
||||
async function loadSchoolDetails(urn) {
|
||||
const data = await fetchAPI(`/api/schools/${urn}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadComparison() {
|
||||
if (selectedSchools.length === 0) return null;
|
||||
|
||||
const urns = selectedSchools.map(s => s.urn).join(',');
|
||||
const data = await fetchAPI(`/api/compare?urns=${urns}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadRankingYears() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
|
||||
// Populate ranking year dropdown
|
||||
elements.rankingYear.innerHTML = '';
|
||||
data.years.sort((a, b) => b - a).forEach(year => {
|
||||
const option = document.createElement('option');
|
||||
@@ -146,6 +221,116 @@ async function loadRankingYears() {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATA LOADING
|
||||
// =============================================================================
|
||||
|
||||
async function loadSchools() {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
renderSchools(state.schools);
|
||||
}
|
||||
|
||||
async function loadFeaturedSchools() {
|
||||
// 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 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('')}
|
||||
`;
|
||||
|
||||
// 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 metric = elements.rankingMetric.value;
|
||||
const year = elements.rankingYear.value;
|
||||
@@ -153,7 +338,8 @@ async function loadRankings() {
|
||||
let endpoint = `/api/rankings?metric=${metric}&limit=20`;
|
||||
if (year) endpoint += `&year=${year}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
const data = await fetchAPI(endpoint, { showLoading: 'rankings' });
|
||||
|
||||
if (!data) {
|
||||
showEmptyState(elements.rankingsList, 'Unable to load rankings');
|
||||
return;
|
||||
@@ -162,14 +348,47 @@ async function loadRankings() {
|
||||
renderRankings(data.rankings, metric);
|
||||
}
|
||||
|
||||
// Render Functions
|
||||
// =============================================================================
|
||||
// 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) {
|
||||
showEmptyState(elements.schoolsGrid, 'No primary schools found matching your criteria');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.schoolsGrid.innerHTML = schools.map(school => `
|
||||
let html = schools.map(school => `
|
||||
<div class="school-card" data-urn="${school.urn}">
|
||||
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
|
||||
<div class="school-meta">
|
||||
@@ -190,6 +409,19 @@ function renderSchools(schools) {
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 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;
|
||||
|
||||
// Add click handlers
|
||||
elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
@@ -199,69 +431,40 @@ function renderSchools(schools) {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRankings(rankings, metric) {
|
||||
if (rankings.length === 0) {
|
||||
showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric');
|
||||
return;
|
||||
}
|
||||
|
||||
const metricLabels = {
|
||||
// Expected
|
||||
rwm_expected_pct: 'RWM %',
|
||||
reading_expected_pct: 'Reading %',
|
||||
writing_expected_pct: 'Writing %',
|
||||
maths_expected_pct: 'Maths %',
|
||||
gps_expected_pct: 'GPS %',
|
||||
science_expected_pct: 'Science %',
|
||||
// Higher
|
||||
rwm_high_pct: 'RWM Higher %',
|
||||
reading_high_pct: 'Reading Higher %',
|
||||
writing_high_pct: 'Writing Higher %',
|
||||
maths_high_pct: 'Maths Higher %',
|
||||
gps_high_pct: 'GPS Higher %',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress',
|
||||
writing_progress: 'Writing Progress',
|
||||
maths_progress: 'Maths Progress',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Avg',
|
||||
maths_avg_score: 'Maths Avg',
|
||||
gps_avg_score: 'GPS Avg',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'Boys RWM %',
|
||||
rwm_expected_girls_pct: 'Girls RWM %',
|
||||
rwm_high_boys_pct: 'Boys Higher %',
|
||||
rwm_high_girls_pct: 'Girls Higher %',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'Disadvantaged %',
|
||||
rwm_expected_non_disadvantaged_pct: 'Non-Disadv %',
|
||||
disadvantaged_gap: 'Disadv Gap',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged',
|
||||
eal_pct: '% EAL',
|
||||
sen_support_pct: '% SEN',
|
||||
stability_pct: '% Stable',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM 3yr %',
|
||||
reading_avg_3yr: 'Reading 3yr',
|
||||
maths_avg_3yr: 'Maths 3yr',
|
||||
};
|
||||
|
||||
elements.rankingsList.innerHTML = rankings.map((school, index) => {
|
||||
const value = school[metric];
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
const isProgress = metric.includes('progress');
|
||||
const isScore = metric.includes('_avg_');
|
||||
let displayValue;
|
||||
if (isProgress) {
|
||||
displayValue = (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
} else if (isScore) {
|
||||
displayValue = value.toFixed(0);
|
||||
} else {
|
||||
displayValue = value.toFixed(0) + '%';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="ranking-item" data-urn="${school.urn}">
|
||||
<div class="ranking-position ${index < 3 ? 'top-3' : ''}">${index + 1}</div>
|
||||
@@ -270,8 +473,8 @@ function renderRankings(rankings, metric) {
|
||||
<div class="ranking-location">${escapeHtml(school.local_authority || '')}</div>
|
||||
</div>
|
||||
<div class="ranking-score">
|
||||
<div class="ranking-score-value">${displayValue}</div>
|
||||
<div class="ranking-score-label">${metricLabels[metric] || metric}</div>
|
||||
<div class="ranking-score-value">${formatMetricValue(value, metric)}</div>
|
||||
<div class="ranking-score-label">${getMetricLabel(metric, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -287,7 +490,7 @@ function renderRankings(rankings, metric) {
|
||||
}
|
||||
|
||||
function renderSelectedSchools() {
|
||||
if (selectedSchools.length === 0) {
|
||||
if (state.selectedSchools.length === 0) {
|
||||
elements.selectedSchools.innerHTML = `
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
@@ -305,7 +508,7 @@ function renderSelectedSchools() {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.selectedSchools.innerHTML = selectedSchools.map((school, index) => `
|
||||
elements.selectedSchools.innerHTML = state.selectedSchools.map((school, index) => `
|
||||
<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">
|
||||
@@ -330,59 +533,18 @@ function renderSelectedSchools() {
|
||||
}
|
||||
|
||||
async function updateComparisonChart() {
|
||||
if (selectedSchools.length === 0) return;
|
||||
if (state.selectedSchools.length === 0) return;
|
||||
|
||||
const data = await loadComparison();
|
||||
if (!data) return;
|
||||
|
||||
const metric = elements.metricSelect.value;
|
||||
const metricLabels = {
|
||||
// Expected Standard
|
||||
rwm_expected_pct: 'Reading, Writing & Maths Combined (%)',
|
||||
reading_expected_pct: 'Reading Expected Standard (%)',
|
||||
writing_expected_pct: 'Writing Expected Standard (%)',
|
||||
maths_expected_pct: 'Maths Expected Standard (%)',
|
||||
gps_expected_pct: 'GPS Expected Standard (%)',
|
||||
science_expected_pct: 'Science Expected Standard (%)',
|
||||
// Higher Standard
|
||||
rwm_high_pct: 'RWM Combined Higher Standard (%)',
|
||||
reading_high_pct: 'Reading Higher Standard (%)',
|
||||
writing_high_pct: 'Writing Greater Depth (%)',
|
||||
maths_high_pct: 'Maths Higher Standard (%)',
|
||||
gps_high_pct: 'GPS Higher Standard (%)',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress Score',
|
||||
writing_progress: 'Writing Progress Score',
|
||||
maths_progress: 'Maths Progress Score',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Average Scaled Score',
|
||||
maths_avg_score: 'Maths Average Scaled Score',
|
||||
gps_avg_score: 'GPS Average Scaled Score',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'RWM Expected % (Boys)',
|
||||
rwm_expected_girls_pct: 'RWM Expected % (Girls)',
|
||||
rwm_high_boys_pct: 'RWM Higher % (Boys)',
|
||||
rwm_high_girls_pct: 'RWM Higher % (Girls)',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'RWM Expected % (Disadvantaged)',
|
||||
rwm_expected_non_disadvantaged_pct: 'RWM Expected % (Non-Disadvantaged)',
|
||||
disadvantaged_gap: 'Disadvantaged Gap vs National',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged Pupils',
|
||||
eal_pct: '% EAL Pupils',
|
||||
sen_support_pct: '% SEN Support',
|
||||
stability_pct: '% Pupil Stability',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM Expected % (3-Year Avg)',
|
||||
reading_avg_3yr: 'Reading Score (3-Year Avg)',
|
||||
maths_avg_3yr: 'Maths Score (3-Year Avg)',
|
||||
};
|
||||
|
||||
// Prepare chart data - iterate in same order as selectedSchools for color consistency
|
||||
// Prepare chart data
|
||||
const datasets = [];
|
||||
const allYears = new Set();
|
||||
|
||||
selectedSchools.forEach((school, index) => {
|
||||
state.selectedSchools.forEach((school, index) => {
|
||||
const schoolData = data.comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
@@ -434,7 +596,7 @@ async function updateComparisonChart() {
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
text: getMetricLabel(metric),
|
||||
font: { family: "'Playfair Display', serif", size: 18, weight: 600 },
|
||||
padding: { bottom: 20 },
|
||||
},
|
||||
@@ -458,7 +620,7 @@ async function updateComparisonChart() {
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
text: getMetricLabel(metric),
|
||||
font: { family: "'DM Sans', sans-serif", weight: 500 },
|
||||
},
|
||||
grid: { color: '#e5dfd5' },
|
||||
@@ -492,9 +654,9 @@ function updateComparisonTable(comparison, metric, years) {
|
||||
}
|
||||
elements.tableHeader.innerHTML = headerHtml;
|
||||
|
||||
// Build body - iterate in same order as selectedSchools for color consistency
|
||||
// Build body
|
||||
let bodyHtml = '';
|
||||
selectedSchools.forEach((school, index) => {
|
||||
state.selectedSchools.forEach((school, index) => {
|
||||
const schoolData = comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
@@ -511,7 +673,7 @@ function updateComparisonTable(comparison, metric, years) {
|
||||
const oneYearChangeStr = oneYearChange !== null ? oneYearChange.toFixed(1) : 'N/A';
|
||||
const oneYearClass = oneYearChange !== null ? (oneYearChange >= 0 ? 'positive' : 'negative') : '';
|
||||
|
||||
// Calculate variability (standard deviation) - exclude null/0 values
|
||||
// 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) {
|
||||
@@ -541,22 +703,21 @@ function updateComparisonTable(comparison, metric, years) {
|
||||
elements.tableBody.innerHTML = bodyHtml;
|
||||
}
|
||||
|
||||
function formatMetricValue(value, metric) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (metric.includes('progress')) {
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
}
|
||||
if (metric.includes('pct')) {
|
||||
return value.toFixed(0) + '%';
|
||||
}
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
async function openSchoolModal(urn) {
|
||||
const data = await loadSchoolDetails(urn);
|
||||
if (!data) return;
|
||||
// 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 = '';
|
||||
|
||||
currentSchoolData = data;
|
||||
const data = await loadSchoolDetails(urn);
|
||||
if (!data) {
|
||||
elements.modalStats.innerHTML = '<div class="empty-state"><p>Unable to load school data</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentSchoolData = data;
|
||||
|
||||
elements.modalSchoolName.textContent = data.school_info.school_name;
|
||||
elements.modalMeta.innerHTML = `
|
||||
@@ -565,7 +726,7 @@ async function openSchoolModal(urn) {
|
||||
<span class="school-tag">Primary</span>
|
||||
`;
|
||||
|
||||
// Get latest year data with actual results (skip 2021 - no SATs)
|
||||
// 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];
|
||||
|
||||
@@ -636,7 +797,7 @@ async function openSchoolModal(urn) {
|
||||
return value >= 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
// Create chart - filter out years with no data (2021)
|
||||
// Create chart
|
||||
if (schoolDetailChart) {
|
||||
schoolDetailChart.destroy();
|
||||
}
|
||||
@@ -702,33 +863,30 @@ async function openSchoolModal(urn) {
|
||||
});
|
||||
|
||||
// Update add to compare button
|
||||
const isSelected = selectedSchools.some(s => s.urn === data.school_info.urn);
|
||||
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;
|
||||
|
||||
elements.modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
elements.modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentSchoolData = null;
|
||||
state.currentSchoolData = null;
|
||||
}
|
||||
|
||||
function addToComparison(school) {
|
||||
if (selectedSchools.some(s => s.urn === school.urn)) return;
|
||||
if (selectedSchools.length >= 5) {
|
||||
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;
|
||||
}
|
||||
|
||||
selectedSchools.push(school);
|
||||
state.selectedSchools.push(school);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
function removeFromComparison(urn) {
|
||||
selectedSchools = selectedSchools.filter(s => s.urn !== urn);
|
||||
state.selectedSchools = state.selectedSchools.filter(s => s.urn !== urn);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
@@ -751,7 +909,10 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
// =============================================================================
|
||||
// EVENT LISTENERS
|
||||
// =============================================================================
|
||||
|
||||
function setupEventListeners() {
|
||||
// Navigation
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
@@ -771,11 +932,18 @@ function setupEventListeners() {
|
||||
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', loadSchools);
|
||||
elements.typeFilter.addEventListener('change', loadSchools);
|
||||
elements.localAuthorityFilter.addEventListener('change', () => {
|
||||
state.pagination.page = 1;
|
||||
loadSchools();
|
||||
});
|
||||
elements.typeFilter.addEventListener('change', () => {
|
||||
state.pagination.page = 1;
|
||||
loadSchools();
|
||||
});
|
||||
|
||||
// Compare search
|
||||
let compareSearchTimeout;
|
||||
@@ -785,7 +953,7 @@ function setupEventListeners() {
|
||||
if (!data) return;
|
||||
lastCompareSearchData = data;
|
||||
|
||||
const results = data.schools.filter(s => !selectedSchools.some(sel => sel.urn === s.urn));
|
||||
const results = data.schools.filter(s => !state.selectedSchools.some(sel => sel.urn === s.urn));
|
||||
|
||||
const headerHtml = `
|
||||
<div class="compare-results-header">
|
||||
@@ -814,7 +982,6 @@ function setupEventListeners() {
|
||||
const school = data.schools.find(s => s.urn === urn);
|
||||
if (school) {
|
||||
addToComparison(school);
|
||||
// Re-render results without closing (filter out newly added school)
|
||||
renderCompareResults(data);
|
||||
}
|
||||
});
|
||||
@@ -841,7 +1008,7 @@ function setupEventListeners() {
|
||||
}
|
||||
|
||||
compareSearchTimeout = setTimeout(async () => {
|
||||
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`);
|
||||
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`, { useCache: false });
|
||||
if (!data) return;
|
||||
|
||||
renderCompareResults(data);
|
||||
@@ -868,16 +1035,16 @@ function setupEventListeners() {
|
||||
elements.modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
|
||||
|
||||
elements.addToCompare.addEventListener('click', () => {
|
||||
if (!currentSchoolData) return;
|
||||
if (!state.currentSchoolData) return;
|
||||
|
||||
const urn = currentSchoolData.school_info.urn;
|
||||
const isSelected = selectedSchools.some(s => s.urn === urn);
|
||||
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(currentSchoolData.school_info);
|
||||
addToComparison(state.currentSchoolData.school_info);
|
||||
elements.addToCompare.textContent = 'Remove from Compare';
|
||||
}
|
||||
});
|
||||
@@ -885,15 +1052,12 @@ function setupEventListeners() {
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Close compare results if open
|
||||
if (elements.compareResults.classList.contains('active')) {
|
||||
elements.compareResults.classList.remove('active');
|
||||
elements.compareSearch.value = '';
|
||||
return;
|
||||
}
|
||||
// Close modal if open
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<section id="dashboard-view" class="view active">
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">Compare Primary School Performance</h1>
|
||||
<p class="hero-subtitle">Explore and compare KS2 results across England's primary schools</p>
|
||||
<p class="hero-subtitle">Search and compare KS2 results across England's primary schools</p>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
|
||||
@@ -269,6 +269,31 @@ body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Featured Schools Header */
|
||||
.featured-header {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 1rem 0 2rem;
|
||||
}
|
||||
|
||||
.featured-header h3 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.featured-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.school-card.featured {
|
||||
border-color: var(--accent-coral);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.school-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -922,6 +947,91 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.school-card.skeleton {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 1rem;
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.ranking-item.skeleton {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.skeleton-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.skeleton-score {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-info {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-load-more {
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
border: 2px solid var(--accent-coral);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--accent-coral);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-load-more:hover {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
|
||||
Reference in New Issue
Block a user