Compare commits

...

4 Commits

Author SHA1 Message Date
Tudor
e0e3bb788e Add list/map view toggle for location search results
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
When searching by location, users can now toggle between list view
(school cards grid) and a split map view showing:
- Interactive map on left with all school markers
- Scrollable school list on right
- Blue marker for search location, default markers for schools
- Clicking a marker highlights and scrolls to the corresponding card

Mobile responsive with stacked layout on smaller screens.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:09:35 +00:00
Tudor
e843394d57 Show progress scores from most recent available year
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
Progress scores aren't available for 2023-24 and 2024-25 due to KS1
SATs being cancelled in 2020-2021. Now the modal finds and displays
progress scores from the most recent year they're available, with
the correct year shown in the header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:15:41 +00:00
Tudor
7919c7b8a5 Add year indicators to school modal sections
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
Display the data year for Progress Scores and School Context sections
in the school details modal, matching the existing KS2 Results format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:56:45 +00:00
Tudor
c27b31220e Replace contact form with mailto link
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
Simplify footer by removing the FormSubmit integration and replacing
it with a direct email link to contact@schoolcompare.co.uk.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:45:16 +00:00
4 changed files with 362 additions and 87 deletions

View File

@@ -350,7 +350,7 @@ async def get_schools(
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"search_location": {"postcode": postcode, "radius": radius} "search_location": {"postcode": postcode, "radius": radius, "lat": search_coords[0], "lng": search_coords[1]}
if search_coords if search_coords
else None, else None,
} }

View File

@@ -22,7 +22,9 @@ const state = {
active: false, active: false,
postcode: null, postcode: null,
radius: 5, radius: 5,
coords: null, // Lat/lng of search location
}, },
resultsView: "list", // "list" or "map"
loading: { loading: {
schools: false, schools: false,
filters: false, filters: false,
@@ -36,6 +38,7 @@ const state = {
let comparisonChart = null; let comparisonChart = null;
let schoolDetailChart = null; let schoolDetailChart = null;
let modalMap = null; let modalMap = null;
let resultsMapInstance = null;
// Chart colors // Chart colors
const CHART_COLORS = [ const CHART_COLORS = [
@@ -189,6 +192,11 @@ const elements = {
radiusSelect: document.getElementById("radius-select"), radiusSelect: document.getElementById("radius-select"),
locationSearchBtn: document.getElementById("location-search-btn"), locationSearchBtn: document.getElementById("location-search-btn"),
typeFilterLocation: document.getElementById("type-filter-location"), typeFilterLocation: document.getElementById("type-filter-location"),
// Results view
viewToggle: document.getElementById("view-toggle"),
viewToggleBtns: document.querySelectorAll(".view-toggle-btn"),
resultsContainer: document.getElementById("results-container"),
resultsMap: document.getElementById("results-map"),
// Schools grid // Schools grid
schoolsGrid: document.getElementById("schools-grid"), schoolsGrid: document.getElementById("schools-grid"),
compareSearch: document.getElementById("compare-search"), compareSearch: document.getElementById("compare-search"),
@@ -522,10 +530,26 @@ async function loadSchools() {
state.pagination.totalPages = data.total_pages; state.pagination.totalPages = data.total_pages;
state.isShowingFeatured = false; state.isShowingFeatured = false;
// Store search coordinates if available
if (data.search_location && data.search_location.lat && data.search_location.lng) {
state.locationSearch.coords = {
lat: data.search_location.lat,
lng: data.search_location.lng,
};
}
// Show location info banner if location search is active // Show location info banner if location search is active
updateLocationInfoBanner(data.search_location); updateLocationInfoBanner(data.search_location);
renderSchools(state.schools); renderSchools(state.schools);
// Update view toggle visibility
updateViewToggle();
// If map view is active, reinitialize the map with new results
if (state.resultsView === "map" && state.locationSearch.active) {
initializeResultsMap(state.schools);
}
} }
async function loadFeaturedSchools() { async function loadFeaturedSchools() {
@@ -548,6 +572,9 @@ async function loadFeaturedSchools() {
state.isShowingFeatured = true; state.isShowingFeatured = true;
renderFeaturedSchools(state.schools); renderFeaturedSchools(state.schools);
// Hide view toggle for featured schools
updateViewToggle();
} }
function updateLocationInfoBanner(searchLocation) { function updateLocationInfoBanner(searchLocation) {
@@ -908,6 +935,168 @@ function openMapModal(lat, lng, schoolName) {
document.addEventListener("keydown", escHandler); document.addEventListener("keydown", escHandler);
} }
/**
* Initialize the results map for location search
*/
function initializeResultsMap(schools) {
// Check if Leaflet is loaded
if (typeof L === "undefined") {
console.warn("Leaflet not loaded, skipping results map initialization");
return;
}
// Destroy existing map if any
if (resultsMapInstance) {
try {
resultsMapInstance.remove();
} catch (e) {
// Ignore cleanup errors
}
resultsMapInstance = null;
}
// Need search coords to center the map
if (!state.locationSearch.coords) {
console.warn("No search coordinates available for results map");
return;
}
const { lat, lng } = state.locationSearch.coords;
// Ensure container has dimensions
setTimeout(() => {
const mapContainer = elements.resultsMap;
if (!mapContainer || mapContainer.offsetWidth === 0 || mapContainer.offsetHeight === 0) {
console.warn("Results map container has no dimensions");
return;
}
// Create map centered on search location
resultsMapInstance = L.map(mapContainer, {
center: [lat, lng],
zoom: 14,
scrollWheelZoom: true,
});
// Add tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(resultsMapInstance);
// Create custom icon for search location (blue)
const searchIcon = L.divIcon({
className: "search-location-marker",
html: `<svg viewBox="0 0 24 24" fill="#3498db" stroke="#2980b9" stroke-width="1" width="32" height="32">
<circle cx="12" cy="12" r="8"/>
</svg>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
// Add search location marker
L.marker([lat, lng], { icon: searchIcon })
.addTo(resultsMapInstance)
.bindPopup(`<strong>Search location</strong><br>${state.locationSearch.postcode}`);
// Add school markers
const bounds = L.latLngBounds([[lat, lng]]);
schools.forEach((school) => {
if (school.latitude && school.longitude) {
const marker = L.marker([school.latitude, school.longitude])
.addTo(resultsMapInstance)
.bindPopup(`
<strong>${escapeHtml(school.school_name)}</strong><br>
${school.distance !== undefined ? school.distance.toFixed(1) + " miles away" : ""}
`);
// Click handler to highlight card
marker.on("click", () => {
highlightSchoolCard(school.urn);
});
bounds.extend([school.latitude, school.longitude]);
}
});
// Fit bounds to show all markers with padding
if (schools.length > 0) {
resultsMapInstance.fitBounds(bounds, { padding: [30, 30] });
}
}, 100);
}
/**
* Highlight a school card and scroll it into view
*/
function highlightSchoolCard(urn) {
// Remove highlight from all cards
document.querySelectorAll(".school-card").forEach((card) => {
card.classList.remove("highlighted");
});
// Add highlight to selected card
const card = document.querySelector(`.school-card[data-urn="${urn}"]`);
if (card) {
card.classList.add("highlighted");
card.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
/**
* Destroy the results map instance
*/
function destroyResultsMap() {
if (resultsMapInstance) {
try {
resultsMapInstance.remove();
} catch (e) {
// Ignore cleanup errors
}
resultsMapInstance = null;
}
}
/**
* Update the view toggle visibility and state
*/
function updateViewToggle() {
// Only show toggle for location search results
if (state.locationSearch.active && state.schools.length > 0) {
elements.viewToggle.style.display = "flex";
} else {
elements.viewToggle.style.display = "none";
// Reset to list view when hiding toggle
if (state.resultsView === "map") {
setResultsView("list");
}
}
}
/**
* Set the results view mode (list or map)
*/
function setResultsView(view) {
state.resultsView = view;
// Update toggle button states
elements.viewToggleBtns.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.view === view);
});
// Update container class
if (view === "map") {
elements.resultsContainer.classList.add("map-view");
// Initialize map if location search is active
if (state.locationSearch.active) {
initializeResultsMap(state.schools);
}
} else {
elements.resultsContainer.classList.remove("map-view");
destroyResultsMap();
}
}
// ============================================================================= // =============================================================================
// RENDER FUNCTIONS // RENDER FUNCTIONS
// ============================================================================= // =============================================================================
@@ -1375,6 +1564,13 @@ async function openSchoolModal(urn) {
const previous = sortedData[latestIndex + 1] || null; const previous = sortedData[latestIndex + 1] || null;
const prevRwm = previous?.rwm_expected_pct; const prevRwm = previous?.rwm_expected_pct;
// Find latest year with progress score data (not available for 2023-24, 2024-25)
const latestWithProgress = sortedData.find(
(d) => d.reading_progress !== null || d.writing_progress !== null || d.maths_progress !== null
);
const progressYear = latestWithProgress?.year || latest.year;
const progressData = latestWithProgress || latest;
elements.modalStats.innerHTML = ` elements.modalStats.innerHTML = `
<div class="modal-stats-section"> <div class="modal-stats-section">
<h4>KS2 Results (${latest.year})</h4> <h4>KS2 Results (${latest.year})</h4>
@@ -1398,24 +1594,24 @@ async function openSchoolModal(urn) {
</div> </div>
</div> </div>
<div class="modal-stats-section"> <div class="modal-stats-section">
<h4>Progress Scores ${createWarningTrigger("progress_scores_unavailable")}</h4> <h4>Progress Scores (${progressYear}) ${createWarningTrigger("progress_scores_unavailable")}</h4>
<div class="modal-stats-grid"> <div class="modal-stats-grid">
<div class="modal-stat"> <div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.reading_progress)}">${formatMetricValue(latest.reading_progress, "reading_progress")}</div> <div class="modal-stat-value ${getProgressClass(progressData.reading_progress)}">${formatMetricValue(progressData.reading_progress, "reading_progress")}</div>
<div class="modal-stat-label">Reading${createInfoTrigger("reading_progress")}</div> <div class="modal-stat-label">Reading${createInfoTrigger("reading_progress")}</div>
</div> </div>
<div class="modal-stat"> <div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.writing_progress)}">${formatMetricValue(latest.writing_progress, "writing_progress")}</div> <div class="modal-stat-value ${getProgressClass(progressData.writing_progress)}">${formatMetricValue(progressData.writing_progress, "writing_progress")}</div>
<div class="modal-stat-label">Writing${createInfoTrigger("writing_progress")}</div> <div class="modal-stat-label">Writing${createInfoTrigger("writing_progress")}</div>
</div> </div>
<div class="modal-stat"> <div class="modal-stat">
<div class="modal-stat-value ${getProgressClass(latest.maths_progress)}">${formatMetricValue(latest.maths_progress, "maths_progress")}</div> <div class="modal-stat-value ${getProgressClass(progressData.maths_progress)}">${formatMetricValue(progressData.maths_progress, "maths_progress")}</div>
<div class="modal-stat-label">Maths${createInfoTrigger("maths_progress")}</div> <div class="modal-stat-label">Maths${createInfoTrigger("maths_progress")}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-stats-section"> <div class="modal-stats-section">
<h4>School Context</h4> <h4>School Context (${latest.year})</h4>
<div class="modal-stats-grid"> <div class="modal-stats-grid">
<div class="modal-stat"> <div class="modal-stat">
<div class="modal-stat-value">${latest.total_pupils || "-"}</div> <div class="modal-stat-value">${latest.total_pupils || "-"}</div>
@@ -1702,11 +1898,14 @@ function setupEventListeners() {
// Clear the inactive mode's state // Clear the inactive mode's state
if (mode === "name") { if (mode === "name") {
// Clear location search state // Clear location search state
state.locationSearch = { active: false, postcode: null, radius: 5 }; state.locationSearch = { active: false, postcode: null, radius: 5, coords: null };
elements.postcodeSearch.value = ""; elements.postcodeSearch.value = "";
elements.radiusSelect.value = "5"; elements.radiusSelect.value = "5";
elements.typeFilterLocation.value = ""; elements.typeFilterLocation.value = "";
updateLocationInfoBanner(null); updateLocationInfoBanner(null);
// Reset to list view and hide toggle
setResultsView("list");
updateViewToggle();
} else { } else {
// Clear name search state // Clear name search state
elements.schoolSearch.value = ""; elements.schoolSearch.value = "";
@@ -1719,6 +1918,16 @@ function setupEventListeners() {
}); });
}); });
// View toggle (list/map)
elements.viewToggleBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const view = btn.dataset.view;
if (view !== state.resultsView) {
setResultsView(view);
}
});
});
// Name search and filters // Name search and filters
let searchTimeout; let searchTimeout;
elements.schoolSearch.addEventListener("input", () => { elements.schoolSearch.addEventListener("input", () => {

View File

@@ -154,9 +154,33 @@
</div> </div>
</div> </div>
<div class="view-toggle" id="view-toggle" style="display: none;">
<button class="view-toggle-btn active" data-view="list">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
List
</button>
<button class="view-toggle-btn" data-view="map">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<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>
Map
</button>
</div>
<div class="results-container" id="results-container">
<div class="results-map" id="results-map"></div>
<div class="schools-grid" id="schools-grid"> <div class="schools-grid" id="schools-grid">
<!-- School cards populated by JS --> <!-- School cards populated by JS -->
</div> </div>
</div>
</section> </section>
<!-- Compare View --> <!-- Compare View -->
@@ -348,19 +372,7 @@
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
<div class="footer-contact"> <div class="footer-contact">
<h3>Contact Us</h3> <a href="mailto:contact@schoolcompare.co.uk">Contact Us</a>
<p>Have questions, feedback, or suggestions? We'd love to hear from you.</p>
<form action="https://formsubmit.co/contact@schoolcompare.co.uk" method="POST" class="contact-form">
<input type="hidden" name="_subject" value="SchoolCompare Contact Form">
<input type="hidden" name="_captcha" value="false">
<input type="text" name="_honey" style="display:none">
<div class="form-row">
<input type="text" name="name" placeholder="Your Name" required class="form-input">
<input type="email" name="email" placeholder="Your Email" required class="form-input">
</div>
<textarea name="message" placeholder="Your Message" required class="form-input form-textarea"></textarea>
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div> </div>
<div class="footer-source"> <div class="footer-source">
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/" target="_blank">UK Government - Compare School Performance</a></p> <p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/" target="_blank">UK Government - Compare School Performance</a></p>

View File

@@ -400,6 +400,108 @@ body {
border-color: var(--accent-teal); border-color: var(--accent-teal);
} }
/* View Toggle */
.view-toggle {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.5rem;
}
.view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
font-size: 0.9rem;
font-family: inherit;
font-weight: 500;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-muted);
cursor: pointer;
transition: var(--transition);
}
.view-toggle-btn:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.view-toggle-btn.active {
background: var(--accent-teal);
color: white;
border-color: var(--accent-teal);
}
.view-toggle-btn svg {
flex-shrink: 0;
}
/* Results Container */
.results-container {
display: block;
}
.results-container .results-map {
display: none;
}
.results-container.map-view {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1.5rem;
min-height: 600px;
}
.results-container.map-view .results-map {
display: block;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-color);
min-height: 600px;
}
.results-container.map-view .schools-grid {
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
max-height: 600px;
padding-right: 0.5rem;
}
.results-container.map-view .schools-grid::-webkit-scrollbar {
width: 6px;
}
.results-container.map-view .schools-grid::-webkit-scrollbar-track {
background: var(--bg-main);
border-radius: 3px;
}
.results-container.map-view .schools-grid::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.results-container.map-view .schools-grid::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Highlighted card in map view */
.school-card.highlighted {
border-color: var(--accent-teal);
box-shadow: 0 0 0 2px rgba(45, 125, 125, 0.2);
}
/* Search location marker on map */
.search-location-marker {
background: transparent;
}
/* Schools Grid */ /* Schools Grid */
.schools-grid { .schools-grid {
display: grid; display: grid;
@@ -669,6 +771,21 @@ body {
.map-modal-content { .map-modal-content {
height: 400px; height: 400px;
} }
.results-container.map-view {
grid-template-columns: 1fr;
grid-template-rows: 350px auto;
min-height: auto;
}
.results-container.map-view .results-map {
min-height: 350px;
}
.results-container.map-view .schools-grid {
max-height: none;
overflow-y: visible;
}
} }
/* Section Titles */ /* Section Titles */
@@ -1210,61 +1327,8 @@ body {
} }
.footer-contact { .footer-contact {
margin-bottom: 2rem; margin-bottom: 1.5rem;
} text-align: center;
.footer-contact h3 {
font-family: 'Playfair Display', serif;
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.footer-contact > p {
color: var(--text-muted);
margin-bottom: 1rem;
}
.contact-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.contact-form .form-row {
display: flex;
gap: 0.75rem;
}
.contact-form .form-input {
flex: 1;
padding: 0.75rem 1rem;
font-family: inherit;
font-size: 0.9rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
transition: var(--transition);
}
.contact-form .form-input:focus {
outline: none;
border-color: var(--accent-teal);
box-shadow: 0 0 0 3px rgba(45, 106, 100, 0.1);
}
.contact-form .form-input::placeholder {
color: var(--text-muted);
}
.contact-form .form-textarea {
min-height: 100px;
resize: vertical;
}
.contact-form .btn {
align-self: flex-start;
} }
.footer-source { .footer-source {
@@ -1282,16 +1346,6 @@ body {
text-decoration: underline; text-decoration: underline;
} }
@media (max-width: 768px) {
.contact-form .form-row {
flex-direction: column;
}
.contact-form .btn {
align-self: stretch;
}
}
/* Loading State */ /* Loading State */
.loading { .loading {
display: flex; display: flex;