// ==UserScript== // @name parkrun Cancellation Impact // @description Analyzes the impact of cancelled parkrun events on nearby alternatives // @author Pete Johns (@johnsyweb) // @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-cancellation-impact.user.js // @grant GM_xmlhttpRequest // @homepage https://johnsy.com/tampermonkey-parkrun/ // @icon https://www.google.com/s2/favicons?sz=64&domain=parkrun.com.au // @license MIT // @match *://www.parkrun.ca/*/results/eventhistory/* // @match *://www.parkrun.co.at/*/results/eventhistory/* // @match *://www.parkrun.co.nl/*/results/eventhistory/* // @match *://www.parkrun.co.nz/*/results/eventhistory/* // @match *://www.parkrun.co.za/*/results/eventhistory/* // @match *://www.parkrun.com.au/*/results/eventhistory/* // @match *://www.parkrun.com.de/*/results/eventhistory/* // @match *://www.parkrun.dk/*/results/eventhistory/* // @match *://www.parkrun.fi/*/results/eventhistory/* // @match *://www.parkrun.fr/*/results/eventhistory/* // @match *://www.parkrun.ie/*/results/eventhistory/* // @match *://www.parkrun.it/*/results/eventhistory/* // @match *://www.parkrun.jp/*/results/eventhistory/* // @match *://www.parkrun.lt/*/results/eventhistory/* // @match *://www.parkrun.my/*/results/eventhistory/* // @match *://www.parkrun.no/*/results/eventhistory/* // @match *://www.parkrun.org.uk/*/results/eventhistory/* // @match *://www.parkrun.pl/*/results/eventhistory/* // @match *://www.parkrun.se/*/results/eventhistory/* // @match *://www.parkrun.sg/*/results/eventhistory/* // @match *://www.parkrun.us/*/results/eventhistory/* // @namespace http://tampermonkey.net/ // @run-at document-end // @supportURL https://github.com/johnsyweb/tampermonkey-parkrun/issues/ // @tag parkrun // @updateURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-cancellation-impact.user.js // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js // @version 0.1.2 // ==/UserScript== (function () { 'use strict'; const STYLES = { backgroundColor: '#1c1b2a', barColor: '#f59e0b', // amber 500 alertColor: '#ef4444', // red 500 lineColor: '#22d3ee', // cyan 400 textColor: '#f3f4f6', subtleTextColor: '#d1d5db', gridColor: 'rgba(243, 244, 246, 0.18)', successColor: '#10b981', // emerald 500 }; const GAP_THRESHOLD_DAYS = 7; const SEASONAL_WEEKS = 12; const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours const state = { currentEvent: null, allParkruns: null, gapInfo: null, nearbyParkruns: [], fetchController: null, analysisComplete: false, impactData: null, currentCancellationIndex: -1, cancellationDates: [], sortColumn: 'distance', sortDirection: 'asc', }; // Parse a YYYY-MM-DD date string as midnight UTC to avoid timezone drift in calculations function parseDateUTC(dateStr) { return new Date(`${dateStr}T00:00:00Z`); } function insertAfterFirst(selector, element) { const pageTitle = document.querySelector(selector); if (pageTitle && pageTitle.parentNode) { if (pageTitle.nextSibling) { pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling); } else { pageTitle.parentNode.appendChild(element); } } } async function fetchAllParkruns() { const CACHE_KEY = 'parkrun_events_cache'; try { const cached = localStorage.getItem(CACHE_KEY); if (cached) { try { const { data, timestamp } = JSON.parse(cached); const age = Date.now() - timestamp; if (age < CACHE_DURATION_MS) { console.log(`Using cached parkrun events (${Math.round(age / 1000 / 60)} minutes old)`); return data; } } catch (parseError) { console.log('Cache parse error, fetching fresh data', parseError); } } console.log('Fetching parkrun events from https://images.parkrun.com/events.json'); const response = await fetch('https://images.parkrun.com/events.json'); if (!response.ok) { console.error('Fetch failed with status:', response.status); return []; } const data = await response.json(); const features = data.events?.features || data.features || []; if (!features || features.length === 0) { console.error('No features found in response data'); return []; } try { localStorage.setItem( CACHE_KEY, JSON.stringify({ data: features, timestamp: Date.now(), }) ); } catch (cacheError) { console.warn('Failed to cache parkrun events:', cacheError); } console.log('Successfully loaded', features.length, 'parkrun events'); return features; } catch (error) { console.error('Failed to fetch parkruns:', error); return []; } } function getCurrentEventInfo() { const pathParts = window.location.pathname.split('/'); const eventName = pathParts[1]; const domain = window.location.hostname; return { eventName, domain, url: window.location.origin, }; } function calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } function findNearbyParkruns(currentEvent, allParkruns, maxDistanceKm = 50) { const current = allParkruns.find((p) => p.properties.eventname === currentEvent.eventName); if (!current) return []; const [currentLon, currentLat] = current.geometry.coordinates; const currentCountry = current.properties.countrycode; const currentSeries = current.properties.seriesid; return allParkruns .filter((parkrun) => { if (parkrun.properties.eventname === currentEvent.eventName) return false; if (parkrun.properties.countrycode !== currentCountry) return false; if (parkrun.properties.seriesid !== currentSeries) return false; const [lon, lat] = parkrun.geometry.coordinates; const latDiff = Math.abs(lat - currentLat); const lonDiff = Math.abs(lon - currentLon); if (latDiff > 0.5 || lonDiff > 0.5) return false; const distance = calculateDistance(currentLat, currentLon, lat, lon); return distance <= maxDistanceKm; }) .map((parkrun) => { const [lon, lat] = parkrun.geometry.coordinates; const distance = calculateDistance(currentLat, currentLon, lat, lon); return { ...parkrun, distance, }; }) .sort((a, b) => a.distance - b.distance); } function extractEventHistoryData() { const title = document.querySelector('h1')?.textContent.trim() ?? 'Event History'; const eventNumbers = []; const dates = []; const rawDates = []; const finishers = []; const volunteers = []; const rows = document.querySelectorAll('tr.Results-table-row'); Array.from(rows) .reverse() .forEach((row) => { const eventNumber = row.getAttribute('data-parkrun'); if (eventNumber) { eventNumbers.push(eventNumber); } const date = row.getAttribute('data-date'); if (date) { rawDates.push(date); const dateObj = new Date(date); const formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); dates.push(formattedDate); } const finishersCount = row.getAttribute('data-finishers'); if (finishersCount) { finishers.push(parseInt(finishersCount, 10)); } const volunteersCount = row.getAttribute('data-volunteers'); if (volunteersCount) { volunteers.push(parseInt(volunteersCount, 10)); } }); return { title, eventNumbers, dates, rawDates, finishers, volunteers, }; } function detectEventGap(historyData) { const dates = historyData.rawDates.map((d) => parseDateUTC(d)); if (dates.length < 2) { return null; } const gaps = []; for (let i = 1; i < dates.length; i++) { const prevDate = dates[i - 1]; const currDate = dates[i]; const daysDiff = (currDate - prevDate) / (1000 * 60 * 60 * 24); if (daysDiff > GAP_THRESHOLD_DAYS) { gaps.push({ gapStartDate: prevDate, gapEndDate: currDate, daysDiff, eventsBefore: i, eventsAfter: dates.length - i, }); } } if (gaps.length === 0) { return null; } // Return latest gap const latestGap = gaps[gaps.length - 1]; console.log( `Detected ${gaps.length} gap(s); using latest: ${latestGap.daysDiff.toFixed(1)} days` ); console.log('All gaps detected:', gaps); return latestGap; } function detectAllEventGaps(historyData) { const dates = historyData.rawDates.map((d) => parseDateUTC(d)); if (dates.length < 2) { return []; } const gaps = []; for (let i = 1; i < dates.length; i++) { const prevDate = dates[i - 1]; const currDate = dates[i]; const daysDiff = (currDate - prevDate) / (1000 * 60 * 60 * 24); if (daysDiff > GAP_THRESHOLD_DAYS) { gaps.push({ gapStartDate: prevDate, gapEndDate: currDate, daysDiff, eventsBefore: i, eventsAfter: dates.length - i, }); } } console.log(`Detected ${gaps.length} total gap(s)`); return gaps; } function getSeasonalWindow(referenceDate, weeksAround = SEASONAL_WEEKS) { const start = new Date(referenceDate); start.setUTCDate(start.getUTCDate() - weeksAround * 7); const end = new Date(referenceDate); end.setUTCDate(end.getUTCDate() + weeksAround * 7); return { start, end }; } function getCancellationSaturdays(gapStartDate, gapEndDate) { const saturdays = []; // Normalize start date to UTC midnight to avoid timezone drift const startStr = gapStartDate.toISOString().split('T')[0]; const startDate = parseDateUTC(startStr); const startDay = startDate.getUTCDay(); let daysUntilSaturday = (6 - startDay) % 7; if (daysUntilSaturday === 0) { daysUntilSaturday = 7; // if already Saturday, move to next Saturday } const current = new Date(startDate); current.setUTCDate(current.getUTCDate() + daysUntilSaturday); while (current < gapEndDate) { saturdays.push(new Date(current)); current.setUTCDate(current.getUTCDate() + 7); } return saturdays; } function findEventOnDate(historyData, targetDate) { const targetStr = targetDate.toISOString().split('T')[0]; for (let i = 0; i < historyData.rawDates.length; i++) { if (historyData.rawDates[i] === targetStr) { return { date: historyData.dates[i], eventNumber: historyData.eventNumbers[i], finishers: historyData.finishers[i], volunteers: historyData.volunteers[i], }; } } return null; } async function fetchEventHistory(eventName, domain) { const CACHE_KEY = `parkrun_history_${eventName}`; try { // Check cache first const cached = localStorage.getItem(CACHE_KEY); if (cached) { try { const { data, timestamp } = JSON.parse(cached); const age = Date.now() - timestamp; if (age < CACHE_DURATION_MS) { console.log( `Using cached history for ${eventName} (${Math.round(age / 1000 / 60)} minutes old)` ); return data; } } catch (parseError) { console.log(`Cache parse error for ${eventName}, fetching fresh data`, parseError); } } // Fetch from network const url = `${domain}/${eventName}/results/eventhistory/`; const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const title = doc.querySelector('h1')?.textContent.trim() ?? eventName; const eventNumbers = []; const dates = []; const rawDates = []; const finishers = []; const volunteers = []; const rows = doc.querySelectorAll('tr.Results-table-row'); Array.from(rows) .reverse() .forEach((row) => { const eventNumber = row.getAttribute('data-parkrun'); if (eventNumber) { eventNumbers.push(eventNumber); } const date = row.getAttribute('data-date'); if (date) { rawDates.push(date); const dateObj = new Date(date); const formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); dates.push(formattedDate); } const finishersCount = row.getAttribute('data-finishers'); if (finishersCount) { finishers.push(parseInt(finishersCount, 10)); } const volunteersCount = row.getAttribute('data-volunteers'); if (volunteersCount) { volunteers.push(parseInt(volunteersCount, 10)); } }); const historyData = { eventName, title, eventNumbers, dates, rawDates, finishers, volunteers, }; // Cache the result try { localStorage.setItem( CACHE_KEY, JSON.stringify({ data: historyData, timestamp: Date.now(), }) ); } catch (cacheError) { console.warn(`Failed to cache history for ${eventName}:`, cacheError); } return historyData; } catch (error) { console.error(`Failed to fetch event history for ${eventName}:`, error); return null; } } function filterEventsByDateRange(historyData, startDate, endDate) { const filtered = { dates: [], finishers: [], volunteers: [], }; historyData.rawDates.forEach((dateStr, index) => { const date = new Date(dateStr); if (date >= startDate && date <= endDate) { filtered.dates.push(historyData.dates[index]); filtered.finishers.push(historyData.finishers[index]); filtered.volunteers.push(historyData.volunteers[index]); } }); return filtered; } function calculateBaseline(data) { if (data.dates.length === 0) { return { avgFinishers: 0, avgVolunteers: 0, totalEvents: 0, minFinishers: 0, maxFinishers: 0, minVolunteers: 0, maxVolunteers: 0, }; } const avgFinishers = Math.round(data.finishers.reduce((a, b) => a + b, 0) / data.dates.length); const avgVolunteers = Math.round( data.volunteers.reduce((a, b) => a + b, 0) / data.dates.length ); return { avgFinishers, avgVolunteers, totalEvents: data.dates.length, minFinishers: Math.min(...data.finishers), maxFinishers: Math.max(...data.finishers), minVolunteers: Math.min(...data.volunteers), maxVolunteers: Math.max(...data.volunteers), }; } function createProgressUI() { const progressSection = document.createElement('div'); progressSection.className = 'parkrun-cancellation-progress'; progressSection.style.padding = '15px'; progressSection.style.backgroundColor = STYLES.backgroundColor; progressSection.style.borderRadius = '6px'; progressSection.style.marginBottom = '15px'; progressSection.style.border = `1px solid ${STYLES.gridColor}`; const heading = document.createElement('h4'); heading.textContent = 'Analyzing Nearby parkrun Impact'; heading.style.margin = '0 0 12px 0'; heading.style.color = STYLES.barColor; progressSection.appendChild(heading); const progressBar = document.createElement('div'); progressBar.style.width = '100%'; progressBar.style.height = '20px'; progressBar.style.backgroundColor = '#3a3250'; progressBar.style.borderRadius = '4px'; progressBar.style.marginBottom = '10px'; progressBar.style.overflow = 'hidden'; const progressFill = document.createElement('div'); progressFill.style.width = '0%'; progressFill.style.height = '100%'; progressFill.style.backgroundColor = STYLES.lineColor; progressFill.style.transition = 'width 0.3s ease'; progressBar.appendChild(progressFill); progressSection.appendChild(progressBar); const progressText = document.createElement('div'); progressText.style.fontSize = '13px'; progressText.style.color = STYLES.subtleTextColor; progressText.style.marginBottom = '12px'; progressSection.appendChild(progressText); const statusText = document.createElement('div'); statusText.style.fontSize = '12px'; statusText.style.color = STYLES.lineColor; statusText.style.fontWeight = 'bold'; statusText.style.marginBottom = '10px'; progressSection.appendChild(statusText); const stopButton = document.createElement('button'); stopButton.textContent = 'Stop Analysis'; stopButton.style.padding = '6px 12px'; stopButton.style.backgroundColor = STYLES.alertColor; stopButton.style.color = STYLES.textColor; stopButton.style.border = 'none'; stopButton.style.borderRadius = '4px'; stopButton.style.cursor = 'pointer'; stopButton.style.fontWeight = 'bold'; stopButton.style.fontSize = '12px'; progressSection.appendChild(stopButton); return { progressSection, updateProgress: (current, total) => { const percent = Math.round((current / total) * 100); progressFill.style.width = percent + '%'; progressText.textContent = `${current}/${total} parkruns analyzed`; }, updateStatus: (message) => { statusText.textContent = message; }, stop: stopButton, hide: () => { progressSection.style.display = 'none'; }, }; } function renderCancellationSummary(eventShortName) { const section = document.createElement('div'); section.style.padding = '20px'; section.style.backgroundColor = '#2b223d'; section.style.borderRadius = '8px'; section.style.marginBottom = '20px'; section.style.border = `1px solid ${STYLES.gridColor}`; const heading = document.createElement('h3'); heading.textContent = 'Cancellation Impact Analysis'; heading.style.color = STYLES.barColor; heading.style.margin = '0 0 15px 0'; heading.style.fontSize = '20px'; section.appendChild(heading); const eventNameDiv = document.createElement('div'); eventNameDiv.style.fontSize = '16px'; eventNameDiv.style.color = STYLES.textColor; eventNameDiv.style.marginBottom = '15px'; eventNameDiv.innerHTML = `${eventShortName}`; section.appendChild(eventNameDiv); const details = document.createElement('div'); details.style.fontSize = '14px'; details.style.lineHeight = '1.8'; details.style.color = STYLES.subtleTextColor; details.style.marginBottom = '18px'; details.innerHTML = `📍 Analyzing impact on nearby parkruns within 50km`; section.appendChild(details); const startButton = document.createElement('button'); startButton.textContent = '▶ Start Analysis'; startButton.className = 'start-analysis-btn'; startButton.style.padding = '12px 24px'; startButton.style.backgroundColor = STYLES.lineColor; startButton.style.color = '#1c1b2a'; startButton.style.border = 'none'; startButton.style.borderRadius = '6px'; startButton.style.cursor = 'pointer'; startButton.style.fontWeight = 'bold'; startButton.style.fontSize = '14px'; startButton.style.transition = 'all 0.2s'; startButton.addEventListener('mouseenter', () => { startButton.style.backgroundColor = '#0ea5e9'; // brighter cyan startButton.style.transform = 'translateY(-1px)'; }); startButton.addEventListener('mouseleave', () => { startButton.style.backgroundColor = STYLES.lineColor; startButton.style.transform = 'translateY(0)'; }); section.appendChild(startButton); return { section, startButton }; } async function renderCancellationAnalysis() { const existing = document.querySelector('.parkrun-cancellation-impact'); if (existing) { existing.remove(); } const historyData = extractEventHistoryData(); if (historyData.eventNumbers.length === 0) { console.log('No event history data found'); return; } const gapInfo = detectEventGap(historyData); if (!gapInfo) { console.log('No cancellation gap detected'); return; } state.currentEvent = { ...historyData, eventName: getCurrentEventInfo().eventName }; state.gapInfo = gapInfo; const eventInfo = getCurrentEventInfo(); const container = document.createElement('div'); container.className = 'parkrun-cancellation-impact'; container.style.width = '100%'; container.style.maxWidth = '1200px'; container.style.margin = '20px auto'; container.style.padding = '15px'; container.style.backgroundColor = STYLES.backgroundColor; container.style.borderRadius = '8px'; container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; // Check for nearby parkruns if (!state.allParkruns || state.allParkruns.length === 0) { const msg = document.createElement('div'); msg.style.padding = '10px'; msg.style.color = STYLES.subtleTextColor; msg.style.textAlign = 'center'; msg.textContent = 'Loading nearby parkruns...'; container.appendChild(msg); insertAfterFirst('h1', container); return; } state.nearbyParkruns = findNearbyParkruns(eventInfo, state.allParkruns); if (state.nearbyParkruns.length === 0) { const msg = document.createElement('div'); msg.style.padding = '10px'; msg.style.color = STYLES.subtleTextColor; msg.style.textAlign = 'center'; msg.textContent = 'No nearby parkruns found within 50km.'; container.appendChild(msg); insertAfterFirst('h1', container); return; } // Get EventShortName for the current event const currentParkrun = state.allParkruns.find( (p) => p.properties.eventname === eventInfo.eventName ); const eventShortName = currentParkrun?.properties?.EventShortName || null; // Cancellation summary with start button const { section: summarySection, startButton } = renderCancellationSummary(eventShortName); container.appendChild(summarySection); insertAfterFirst('h1', container); // Setup analysis trigger startButton.addEventListener('click', () => { startButton.disabled = true; startButton.textContent = 'Starting...'; startButton.style.opacity = '0.6'; startButton.style.cursor = 'not-allowed'; // Create and show progress UI const progressUI = createProgressUI(); summarySection.insertAdjacentElement('afterend', progressUI.progressSection); // Background fetch state.fetchController = new AbortController(); startBackgroundAnalysis(progressUI, container, summarySection); }); } async function startBackgroundAnalysis(progressUI, container, summarySection) { const eventInfo = getCurrentEventInfo(); const nearbyParkruns = state.nearbyParkruns; // Find all cancellation Saturdays from all gaps const allGaps = detectAllEventGaps(state.currentEvent); const cancellationSaturdays = []; allGaps.forEach((gap) => { const saturdays = getCancellationSaturdays(gap.gapStartDate, gap.gapEndDate); cancellationSaturdays.push(...saturdays); }); // Sort by date descending (newest first) cancellationSaturdays.sort((a, b) => b - a); state.cancellationDates = cancellationSaturdays; console.log('All Cancellation Saturdays:', cancellationSaturdays); // Fetch all nearby parkrun histories once const nearbyHistories = []; for (let i = 0; i < nearbyParkruns.length; i++) { if (state.fetchController.signal.aborted) { console.log('Analysis stopped by user'); break; } const parkrun = nearbyParkruns[i]; const eventName = parkrun.properties.eventname; const shortName = parkrun.properties.EventShortName || eventName; const distance = parkrun.distance.toFixed(1); progressUI.updateStatus(`Fetching: ${shortName} (${distance}km)`); progressUI.updateProgress(i, nearbyParkruns.length); try { const historyData = await fetchEventHistory(eventName, eventInfo.url); if (historyData) { nearbyHistories.push({ parkrun, historyData, shortName, distance, }); } } catch (error) { console.error(`Failed to fetch ${eventName}:`, error); } await new Promise((resolve) => setTimeout(resolve, 100)); } // Compute results for all cancellation dates const resultsByDate = {}; const validCancellationDates = []; cancellationSaturdays.forEach((targetDate) => { const dateKey = targetDate.toISOString().split('T')[0]; const results = []; const windowBefore = getSeasonalWindow(targetDate, SEASONAL_WEEKS); windowBefore.end = new Date(targetDate); windowBefore.end.setUTCDate(windowBefore.end.getUTCDate() - 1); nearbyHistories.forEach(({ parkrun, historyData, shortName, distance }) => { const beforeData = filterEventsByDateRange( historyData, windowBefore.start, windowBefore.end ); const baseline = calculateBaseline(beforeData); // Find event on this cancellation date const eventOnDate = findEventOnDate(historyData, targetDate); results.push({ eventName: parkrun.properties.eventname, title: historyData.title, displayName: shortName, distance, baseline, eventOnDate, seasonalTrend: buildSeasonalTrend(historyData, targetDate), change: eventOnDate ? { finishersChange: eventOnDate.finishers - baseline.avgFinishers, volunteersChange: eventOnDate.volunteers - baseline.avgVolunteers, finishersPct: baseline.avgFinishers > 0 ? ((eventOnDate.finishers - baseline.avgFinishers) / baseline.avgFinishers) * 100 : 0, volunteersPct: baseline.avgVolunteers > 0 ? ((eventOnDate.volunteers - baseline.avgVolunteers) / baseline.avgVolunteers) * 100 : 0, } : null, }); }); // Check if this was a global cancellation (no parkruns ran) const eventsHeld = results.filter((r) => r.eventOnDate).length; if (eventsHeld >= 1) { resultsByDate[dateKey] = results; validCancellationDates.push(targetDate); } else { console.log(`Skipping ${dateKey}: 0/${results.length} parkruns ran (global cancellation)`); } }); // Use filtered valid dates const finalCancellationDates = validCancellationDates.length > 0 ? validCancellationDates : cancellationSaturdays; progressUI.updateProgress(nearbyParkruns.length, nearbyParkruns.length); if (validCancellationDates.length === 0) { progressUI.updateStatus( 'No valid cancellation dates found - all detected dates had global cancellations' ); progressUI.stop.textContent = 'Close'; progressUI.stop.style.backgroundColor = STYLES.alertColor; const noDataMsg = document.createElement('div'); noDataMsg.style.padding = '15px'; noDataMsg.style.backgroundColor = '#3a3250'; noDataMsg.style.borderRadius = '6px'; noDataMsg.style.marginTop = '15px'; noDataMsg.style.color = STYLES.textColor; noDataMsg.style.textAlign = 'center'; noDataMsg.innerHTML = `

⚠ No Valid Analysis Dates

All detected cancellation dates appear to be part of global cancellation periods (e.g., COVID-19).

No nearby parkruns held events on these dates, indicating system-wide cancellations rather than single-event cancellations.

`; container.appendChild(noDataMsg); progressUI.stop.addEventListener('click', () => { progressUI.hide(); }); return; } progressUI.updateStatus( `Analysis complete! Found ${validCancellationDates.length} valid cancellation date(s)` ); const startBtn = document.querySelector('.start-analysis-btn'); if (startBtn) { startBtn.style.display = 'none'; } progressUI.stop.textContent = 'Close'; progressUI.stop.style.backgroundColor = STYLES.successColor; state.resultsByDate = resultsByDate; state.cancellationDates = finalCancellationDates; state.analysisComplete = true; // Set initial index and render if (state.currentCancellationIndex === -1 && finalCancellationDates.length > 0) { state.currentCancellationIndex = 0; // Start with first (newest) date } // Create navigation controls at the top const navSection = createNavigationControls( container, resultsByDate, finalCancellationDates, state.currentCancellationIndex ); summarySection.insertAdjacentElement('afterend', navSection); renderImpactResults( container, resultsByDate, finalCancellationDates, state.currentCancellationIndex ); // Auto-hide progress UI after results are shown setTimeout(() => { progressUI.hide(); }, 500); // Close progress UI on click (if user wants to close it manually before auto-hide) progressUI.stop.addEventListener('click', () => { progressUI.hide(); }); } function createNavigationControls(container, resultsByDate, cancellationDates, currentDateIndex) { const navSection = document.createElement('div'); navSection.className = 'parkrun-cancellation-nav'; navSection.style.padding = '15px'; navSection.style.backgroundColor = '#2b223d'; navSection.style.borderRadius = '8px'; navSection.style.marginBottom = '20px'; navSection.style.border = `1px solid ${STYLES.gridColor}`; const navInfo = document.createElement('div'); navInfo.style.color = STYLES.textColor; navInfo.style.fontSize = '14px'; navInfo.style.marginBottom = '12px'; navInfo.innerHTML = ` ${cancellationDates.length} Cancellation Date${cancellationDates.length !== 1 ? 's' : ''} Available
Use dropdown or buttons to navigate • Keyboard:
`; navSection.appendChild(navInfo); const navControlsWrapper = document.createElement('div'); navControlsWrapper.style.display = 'flex'; navControlsWrapper.style.alignItems = 'center'; navControlsWrapper.style.gap = '8px'; navControlsWrapper.style.flexWrap = 'wrap'; const prevBtn = document.createElement('button'); prevBtn.textContent = '←'; prevBtn.style.padding = '6px 10px'; const prevEnabled = currentDateIndex < cancellationDates.length - 1; prevBtn.style.backgroundColor = prevEnabled ? STYLES.lineColor : '#3a3250'; prevBtn.style.color = prevEnabled ? '#2b223d' : STYLES.subtleTextColor; prevBtn.style.border = `1px solid ${STYLES.gridColor}`; prevBtn.style.borderRadius = '4px'; prevBtn.style.cursor = prevEnabled ? 'pointer' : 'not-allowed'; prevBtn.style.fontWeight = 'bold'; prevBtn.style.fontSize = '14px'; prevBtn.style.transition = 'all 0.2s ease'; prevBtn.disabled = !prevEnabled; if (prevEnabled) { prevBtn.addEventListener('mouseenter', () => { prevBtn.style.transform = 'translateY(-1px)'; prevBtn.style.boxShadow = '0 2px 4px rgba(34, 211, 238, 0.3)'; }); prevBtn.addEventListener('mouseleave', () => { prevBtn.style.transform = 'translateY(0)'; prevBtn.style.boxShadow = 'none'; }); } const dateDropdown = document.createElement('select'); dateDropdown.style.padding = '6px 8px'; dateDropdown.style.backgroundColor = '#3a3250'; dateDropdown.style.color = STYLES.textColor; dateDropdown.style.border = `1px solid ${STYLES.gridColor}`; dateDropdown.style.borderRadius = '4px'; dateDropdown.style.cursor = 'pointer'; dateDropdown.style.fontWeight = 'bold'; dateDropdown.style.fontSize = '12px'; dateDropdown.style.minWidth = '220px'; dateDropdown.style.flex = '1'; cancellationDates.forEach((date, index) => { const option = document.createElement('option'); const optionDateStr = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short', }); option.value = index; option.textContent = optionDateStr; option.selected = index === currentDateIndex; dateDropdown.appendChild(option); }); const nextBtn = document.createElement('button'); nextBtn.textContent = '→'; nextBtn.style.padding = '6px 10px'; const nextEnabled = currentDateIndex > 0; nextBtn.style.backgroundColor = nextEnabled ? STYLES.lineColor : '#3a3250'; nextBtn.style.color = nextEnabled ? '#2b223d' : STYLES.subtleTextColor; nextBtn.style.border = `1px solid ${STYLES.gridColor}`; nextBtn.style.borderRadius = '4px'; nextBtn.style.cursor = nextEnabled ? 'pointer' : 'not-allowed'; nextBtn.style.fontWeight = 'bold'; nextBtn.style.fontSize = '14px'; nextBtn.style.transition = 'all 0.2s ease'; nextBtn.disabled = !nextEnabled; if (nextEnabled) { nextBtn.addEventListener('mouseenter', () => { nextBtn.style.transform = 'translateY(-1px)'; nextBtn.style.boxShadow = '0 2px 4px rgba(34, 211, 238, 0.3)'; }); nextBtn.addEventListener('mouseleave', () => { nextBtn.style.transform = 'translateY(0)'; nextBtn.style.boxShadow = 'none'; }); } navControlsWrapper.appendChild(prevBtn); navControlsWrapper.appendChild(dateDropdown); navControlsWrapper.appendChild(nextBtn); navSection.appendChild(navControlsWrapper); const updateResults = () => { const resultsDiv = container.querySelector('.parkrun-cancellation-results'); if (resultsDiv) { resultsDiv.remove(); } renderImpactResults( container, resultsByDate, cancellationDates, state.currentCancellationIndex ); // Refresh nav UI state (buttons/dropdown) to reflect new index const latestNav = container.querySelector('.parkrun-cancellation-nav'); if (latestNav) { const selects = latestNav.querySelectorAll('select'); if (selects[0]) selects[0].value = state.currentCancellationIndex; const buttons = latestNav.querySelectorAll('button'); const prev = buttons[0]; const next = buttons[1]; if (prev) { const enabled = state.currentCancellationIndex < cancellationDates.length - 1; prev.disabled = !enabled; prev.style.backgroundColor = enabled ? STYLES.lineColor : '#3a3250'; prev.style.color = enabled ? '#2b223d' : STYLES.subtleTextColor; prev.style.cursor = enabled ? 'pointer' : 'not-allowed'; } if (next) { const enabled = state.currentCancellationIndex > 0; next.disabled = !enabled; next.style.backgroundColor = enabled ? STYLES.lineColor : '#3a3250'; next.style.color = enabled ? '#2b223d' : STYLES.subtleTextColor; next.style.cursor = enabled ? 'pointer' : 'not-allowed'; } } }; const handlePrev = () => { if (state.currentCancellationIndex < cancellationDates.length - 1) { state.currentCancellationIndex += 1; // move to older date updateResults(); } }; const handleNext = () => { if (state.currentCancellationIndex > 0) { state.currentCancellationIndex -= 1; // move to newer date updateResults(); } }; const handleDropdownChange = (e) => { state.currentCancellationIndex = parseInt(e.target.value, 10); updateResults(); }; prevBtn.addEventListener('click', handlePrev); nextBtn.addEventListener('click', handleNext); dateDropdown.addEventListener('change', handleDropdownChange); // Keyboard navigation (one handler at a time) if (state.keyboardHandler) { document.removeEventListener('keydown', state.keyboardHandler); } state.keyboardHandler = (e) => { if (e.key === 'ArrowLeft') handlePrev(); if (e.key === 'ArrowRight') handleNext(); }; document.addEventListener('keydown', state.keyboardHandler); return navSection; } function sortResults(results, column, direction) { const sorted = [...results]; const getValue = (result) => { switch (column) { case 'name': return result.displayName || result.eventName; case 'distance': return parseFloat(result.distance); case 'eventNumber': return result.eventOnDate ? parseInt(result.eventOnDate.eventNumber, 10) : -1; case 'baseline': return result.baseline.avgFinishers; case 'onDate': return result.eventOnDate ? result.eventOnDate.finishers : -1; case 'change': return result.change ? result.change.finishersChange : -999999; case 'changePct': return result.change ? result.change.finishersPct : -999999; default: return 0; } }; sorted.sort((a, b) => { const aVal = getValue(a); const bVal = getValue(b); return direction === 'asc' ? aVal - bVal : bVal - aVal; }); return sorted; } async function buildHtmlReport(resultsSection, meta) { const clone = resultsSection.cloneNode(true); const originalCanvases = resultsSection.querySelectorAll('canvas'); const clonedCanvases = clone.querySelectorAll('canvas'); originalCanvases.forEach((canvas, idx) => { try { const dataUrl = canvas.toDataURL('image/png'); const img = document.createElement('img'); img.src = dataUrl; img.alt = 'Chart snapshot'; img.style.maxWidth = '100%'; img.style.display = 'block'; img.style.backgroundColor = '#2b223d'; if (clonedCanvases[idx]) { clonedCanvases[idx].replaceWith(img); } } catch (error) { console.error('Failed to serialize chart canvas:', error); } }); const stylesheet = ` :root { color-scheme: dark; } body { margin: 0; padding: 20px; background: ${STYLES.backgroundColor}; color: ${STYLES.textColor}; font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; line-height: 1.5; } a { color: ${STYLES.lineColor}; } h1, h2, h3, h4 { color: ${STYLES.barColor}; margin: 0 0 10px 0; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid ${STYLES.gridColor}; padding: 10px; text-align: left; } th { background: #2b223d; color: ${STYLES.barColor}; } tr:nth-child(even) td { background: #241c35; } tr:nth-child(odd) td { background: #1f182e; } .parkrun-cancellation-results { background: ${STYLES.backgroundColor}; padding: 16px; border-radius: 6px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); } .chart-img { max-width: 100%; display: block; } .meta { margin-bottom: 16px; color: ${STYLES.subtleTextColor}; font-size: 13px; } .meta strong { color: ${STYLES.textColor}; } `; const header = `

parkrun Cancellation Impact

Event: ${meta.eventShortName}
Cancelled date: ${meta.cancellationDateStr}
Generated: ${meta.generatedAt}
`; return `parkrun Cancellation Impact - ${meta.eventShortName} - ${meta.cancellationDateStr}${header}${clone.outerHTML}`; } async function generateReportBlob(resultsSection, meta) { const html = await buildHtmlReport(resultsSection, meta); const filename = `parkrun-cancellation-impact-${meta.eventShortName}-${meta.cancellationDateStr}.html`; return { blob: new Blob([html], { type: 'text/html' }), filename, }; } function buildSeasonalTrend(historyData, targetDate) { const windowBefore = getSeasonalWindow(targetDate, SEASONAL_WEEKS); windowBefore.end = new Date(targetDate); windowBefore.end.setUTCDate(windowBefore.end.getUTCDate() - 1); const filtered = filterEventsByDateRange(historyData, windowBefore.start, windowBefore.end); const baseline = calculateBaseline(filtered); return { window: windowBefore, filtered, baseline, }; } function renderImpactResults( resultsContainer, resultsByDate, cancellationDates, currentDateIndex ) { // Get results for current date const currentDate = cancellationDates[currentDateIndex]; const dateKey = currentDate.toISOString().split('T')[0]; const results = resultsByDate[dateKey] || []; const dateStr = currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); // Results section const resultsSection = document.createElement('div'); resultsSection.className = 'parkrun-cancellation-results'; resultsSection.style.marginTop = '20px'; // Impact heading with date const tableHeading = document.createElement('h3'); tableHeading.textContent = `Nearby parkrun Impact on ${dateStr}`; tableHeading.style.color = STYLES.barColor; tableHeading.style.marginTop = '20px'; tableHeading.style.marginBottom = '12px'; resultsSection.appendChild(tableHeading); const tableWrap = document.createElement('div'); tableWrap.style.overflowX = 'auto'; const table = document.createElement('table'); table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.color = STYLES.textColor; const thead = document.createElement('thead'); thead.style.position = 'sticky'; thead.style.top = '0'; thead.style.backgroundColor = '#2b223d'; thead.style.zIndex = '10'; const headerRow = document.createElement('tr'); headerRow.style.borderBottom = `2px solid ${STYLES.gridColor}`; const headers = [ { label: 'parkrun', key: 'name', align: 'left', info: 'Click column headers to sort. Default ordering is by distance.', }, { label: 'Distance', key: 'distance', align: 'right', info: 'Distance from cancelled event in kilometers.', }, { label: 'Event #', key: 'eventNumber', align: 'right', info: `Event number on ${dateStr}. Lower numbers indicate newer parkruns.`, }, { label: 'Baseline (Avg)', key: 'baseline', align: 'right', info: `12-week seasonal average (finishers/volunteers) ending the day before ${dateStr}.`, }, { label: 'On Date', key: 'onDate', align: 'right', info: `Actual attendance (finishers/volunteers) on ${dateStr}.`, }, { label: 'Change', key: 'change', align: 'right', info: 'Difference between actual and baseline (finishers/volunteers).', }, { label: 'Change %', key: 'changePct', align: 'right', info: 'Percentage change in finishers compared to baseline.', }, { label: 'Trend', key: 'trend', align: 'right', info: 'Gain (+5 or more finishers), Loss (-5 or fewer), Stable (within ±5), or No Event.', }, ]; const renderTable = (sortedResults) => { // Clear existing tbody if present const existingTbody = table.querySelector('tbody'); if (existingTbody) { existingTbody.remove(); } const tbody = document.createElement('tbody'); sortedResults.forEach((result) => { const row = document.createElement('tr'); row.style.borderBottom = `1px solid ${STYLES.gridColor}`; row.style.transition = 'background-color 0.15s ease'; // Special styling for No Event rows const hasEvent = result.eventOnDate !== null; if (!hasEvent) { row.style.opacity = '0.6'; } // Add hover effect row.addEventListener('mouseenter', () => { row.style.backgroundColor = hasEvent ? 'rgba(34, 211, 238, 0.08)' : 'rgba(243, 244, 246, 0.03)'; }); row.addEventListener('mouseleave', () => { row.style.backgroundColor = 'transparent'; }); // parkrun name with link const nameCell = document.createElement('td'); nameCell.style.padding = '10px'; nameCell.style.textAlign = 'left'; nameCell.style.fontWeight = 'bold'; const link = document.createElement('a'); link.href = `${getCurrentEventInfo().url}/${result.eventName}/results/eventhistory/`; link.textContent = result.displayName || result.eventName; link.style.color = STYLES.lineColor; link.style.textDecoration = 'none'; link.target = '_blank'; link.addEventListener('mouseenter', () => { link.style.textDecoration = 'underline'; }); link.addEventListener('mouseleave', () => { link.style.textDecoration = 'none'; }); nameCell.appendChild(link); row.appendChild(nameCell); const distanceCell = document.createElement('td'); distanceCell.style.padding = '10px'; distanceCell.style.textAlign = 'right'; distanceCell.style.color = STYLES.subtleTextColor; distanceCell.textContent = `${result.distance}km`; row.appendChild(distanceCell); const eventNumberCell = document.createElement('td'); eventNumberCell.style.padding = '10px'; eventNumberCell.style.textAlign = 'right'; if (result.eventOnDate && result.eventOnDate.eventNumber) { eventNumberCell.textContent = result.eventOnDate.eventNumber; eventNumberCell.style.color = STYLES.textColor; } else { eventNumberCell.textContent = '—'; eventNumberCell.style.color = STYLES.subtleTextColor; } row.appendChild(eventNumberCell); const baselineCell = document.createElement('td'); baselineCell.style.padding = '10px'; baselineCell.style.textAlign = 'right'; baselineCell.innerHTML = `${result.baseline.avgFinishers} / ${result.baseline.avgVolunteers}`; row.appendChild(baselineCell); const onDateCell = document.createElement('td'); onDateCell.style.padding = '10px'; onDateCell.style.textAlign = 'right'; if (result.eventOnDate) { onDateCell.innerHTML = `${result.eventOnDate.finishers} / ${result.eventOnDate.volunteers}`; } else { onDateCell.textContent = '—'; onDateCell.style.color = STYLES.subtleTextColor; } row.appendChild(onDateCell); const changeCell = document.createElement('td'); changeCell.style.padding = '10px'; changeCell.style.textAlign = 'right'; if (result.change) { const finishersSign = result.change.finishersChange > 0 ? '+' : ''; const volunteersSign = result.change.volunteersChange > 0 ? '+' : ''; const finishersColor = result.change.finishersChange > 0 ? STYLES.successColor : STYLES.alertColor; const volunteersColor = result.change.volunteersChange > 0 ? STYLES.successColor : STYLES.alertColor; changeCell.innerHTML = ` ${finishersSign}${result.change.finishersChange} / ${volunteersSign}${result.change.volunteersChange} `; } else { changeCell.textContent = '—'; changeCell.style.color = STYLES.subtleTextColor; } row.appendChild(changeCell); const changePctCell = document.createElement('td'); changePctCell.style.padding = '10px'; changePctCell.style.textAlign = 'right'; if (result.change) { const pctColor = result.change.finishersPct > 0 ? STYLES.successColor : STYLES.alertColor; const sign = result.change.finishersPct > 0 ? '+' : ''; changePctCell.innerHTML = `${sign}${result.change.finishersPct.toFixed(1)}%`; } else { changePctCell.textContent = '—'; changePctCell.style.color = STYLES.subtleTextColor; } row.appendChild(changePctCell); const trendCell = document.createElement('td'); trendCell.style.padding = '10px'; trendCell.style.textAlign = 'right'; if (!result.eventOnDate) { trendCell.textContent = 'No Event'; trendCell.style.color = STYLES.subtleTextColor; } else if (result.change.finishersChange < -5) { trendCell.textContent = '↓ Loss'; trendCell.style.color = STYLES.alertColor; } else if (result.change.finishersChange > 5) { trendCell.textContent = '↑ Gain'; trendCell.style.color = STYLES.successColor; } else { trendCell.textContent = '→ Stable'; trendCell.style.color = STYLES.subtleTextColor; } row.appendChild(trendCell); tbody.appendChild(row); }); table.appendChild(tbody); }; headers.forEach((header) => { const th = document.createElement('th'); th.style.padding = '10px'; th.style.textAlign = header.align; th.style.color = STYLES.barColor; th.style.fontWeight = 'bold'; th.style.cursor = 'pointer'; th.style.userSelect = 'none'; th.style.position = 'relative'; th.style.transition = 'background-color 0.15s ease'; th.addEventListener('mouseenter', () => { th.style.backgroundColor = 'rgba(34, 211, 238, 0.1)'; }); th.addEventListener('mouseleave', () => { th.style.backgroundColor = 'transparent'; }); const headerText = document.createElement('span'); headerText.textContent = header.label; headerText.style.marginRight = '4px'; th.appendChild(headerText); // Sort indicator const sortIndicator = document.createElement('span'); sortIndicator.style.fontSize = '10px'; sortIndicator.style.opacity = state.sortColumn === header.key ? '1' : '0.3'; sortIndicator.textContent = state.sortColumn === header.key ? (state.sortDirection === 'asc' ? '▲' : '▼') : '▲'; th.appendChild(sortIndicator); // Info icon with tooltip const infoIcon = document.createElement('span'); infoIcon.textContent = ' ℹ'; infoIcon.style.fontSize = '12px'; infoIcon.style.opacity = '0.6'; infoIcon.style.cursor = 'help'; infoIcon.style.transition = 'opacity 0.2s ease'; infoIcon.title = header.info; infoIcon.addEventListener('mouseenter', () => { infoIcon.style.opacity = '1'; }); infoIcon.addEventListener('mouseleave', () => { infoIcon.style.opacity = '0.6'; }); th.appendChild(infoIcon); th.addEventListener('click', () => { if (state.sortColumn === header.key) { state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'; } else { state.sortColumn = header.key; state.sortDirection = 'asc'; } const sortedResults = sortResults(results, state.sortColumn, state.sortDirection); renderTable(sortedResults); // Update all header indicators headers.forEach((h, idx) => { const headerCell = headerRow.children[idx]; const indicator = headerCell.children[1]; if (h.key === state.sortColumn) { indicator.style.opacity = '1'; indicator.textContent = state.sortDirection === 'asc' ? '▲' : '▼'; } else { indicator.style.opacity = '0.3'; indicator.textContent = '▲'; } }); }); headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); // Initial render with default sorting (distance, asc) const sortedResults = sortResults(results, state.sortColumn, state.sortDirection); renderTable(sortedResults); tableWrap.appendChild(table); resultsSection.appendChild(tableWrap); // Seasonal trend for cancelled event const seasonalTrend = buildSeasonalTrend(state.currentEvent, currentDate); const trendSection = document.createElement('div'); trendSection.style.marginTop = '16px'; trendSection.style.padding = '12px'; trendSection.style.backgroundColor = '#3a3250'; trendSection.style.borderRadius = '4px'; const trendHeading = document.createElement('h3'); trendHeading.textContent = 'Cancelled Event Seasonal Trend'; trendHeading.style.color = STYLES.barColor; trendHeading.style.margin = '0 0 8px 0'; trendSection.appendChild(trendHeading); const windowText = document.createElement('div'); const startStr = seasonalTrend.window.start.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); const endStr = seasonalTrend.window.end.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); windowText.style.color = STYLES.textColor; windowText.style.fontSize = '13px'; windowText.style.marginBottom = '6px'; windowText.innerHTML = `Window: ${startStr} → ${endStr} (12-week baseline)`; trendSection.appendChild(windowText); const trendStats = document.createElement('div'); trendStats.style.color = STYLES.textColor; trendStats.style.fontSize = '13px'; trendStats.style.display = 'grid'; trendStats.style.gridTemplateColumns = 'repeat(auto-fit, minmax(200px, 1fr))'; trendStats.style.gap = '8px'; trendStats.innerHTML = `
Average finishers: ${seasonalTrend.baseline.avgFinishers}
Min finishers: ${seasonalTrend.baseline.minFinishers}
Max finishers: ${seasonalTrend.baseline.maxFinishers}
Average volunteers: ${seasonalTrend.baseline.avgVolunteers}
Min volunteers: ${seasonalTrend.baseline.minVolunteers}
Max volunteers: ${seasonalTrend.baseline.maxVolunteers}
Total events: ${seasonalTrend.baseline.totalEvents}
`; trendSection.appendChild(trendStats); if ( typeof Chart !== 'undefined' && seasonalTrend.filtered.finishers && seasonalTrend.filtered.finishers.length > 0 ) { const trendCanvas = document.createElement('canvas'); trendCanvas.style.marginTop = '12px'; trendSection.appendChild(trendCanvas); // eslint-disable-next-line no-undef new Chart(trendCanvas.getContext('2d'), { type: 'line', data: { labels: seasonalTrend.filtered.dates, datasets: [ { label: 'Finishers', data: seasonalTrend.filtered.finishers, borderColor: STYLES.lineColor, backgroundColor: 'rgba(34, 211, 238, 0.25)', tension: 0.2, fill: true, }, { label: 'Volunteers', data: seasonalTrend.filtered.volunteers, borderColor: STYLES.successColor, backgroundColor: 'rgba(16, 185, 129, 0.18)', tension: 0.2, fill: true, }, { label: 'Finishers baseline avg', data: seasonalTrend.filtered.dates.map(() => seasonalTrend.baseline.avgFinishers), borderColor: STYLES.barColor, borderDash: [6, 4], pointRadius: 0, }, { label: 'Volunteers baseline avg', data: seasonalTrend.filtered.dates.map(() => seasonalTrend.baseline.avgVolunteers), borderColor: STYLES.gridColor, borderDash: [6, 4], pointRadius: 0, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.6, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Finishers & volunteers over baseline window', color: STYLES.textColor, }, }, scales: { x: { ticks: { color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, y: { beginAtZero: true, ticks: { precision: 0, color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, title: { display: true, text: 'Finishers', color: STYLES.textColor }, }, }, }, }); } else { const emptyMsg = document.createElement('div'); emptyMsg.style.color = STYLES.subtleTextColor; emptyMsg.style.fontSize = '12px'; emptyMsg.style.marginTop = '8px'; emptyMsg.textContent = 'No historical data available for the baseline window.'; trendSection.appendChild(emptyMsg); } resultsSection.appendChild(trendSection); // Navigation handled by top-level controls injected after summary // Most impacted events seasonal trends const positiveChanges = results.filter( (r) => r.change && r.change.finishersChange > 0 && r.change.finishersPct !== undefined ); const topAbsolute = [...positiveChanges] .sort((a, b) => b.change.finishersChange - a.change.finishersChange) .slice(0, 1); const topRelative = [...positiveChanges] .filter((r) => r.change.finishersPct > 0) .sort((a, b) => b.change.finishersPct - a.change.finishersPct) .slice(0, 1); const impactedSection = document.createElement('div'); impactedSection.style.marginTop = '18px'; impactedSection.style.padding = '12px'; impactedSection.style.backgroundColor = '#2b223d'; impactedSection.style.borderRadius = '4px'; const impactedHeading = document.createElement('h3'); impactedHeading.textContent = 'Most Impacted parkruns'; impactedHeading.style.color = STYLES.barColor; impactedHeading.style.margin = '0 0 10px 0'; impactedSection.appendChild(impactedHeading); const impactedSummary = document.createElement('div'); impactedSummary.style.color = STYLES.textColor; impactedSummary.style.fontSize = '13px'; if (topAbsolute.length === 0) { impactedSummary.textContent = 'No nearby parkruns saw an attendance increase on this date.'; impactedSection.appendChild(impactedSummary); } else { const summaries = []; if (topAbsolute[0]) { const r = topAbsolute[0]; summaries.push( `Largest absolute gain: ${r.displayName || r.eventName} (+${r.change.finishersChange} finishers, ${r.change.finishersPct.toFixed(1)}%)` ); } if (topRelative[0]) { const r = topRelative[0]; summaries.push( `Largest relative gain: ${r.displayName || r.eventName} (+${r.change.finishersPct.toFixed(1)}%, +${r.change.finishersChange} finishers)` ); } impactedSummary.innerHTML = summaries.join('
'); impactedSection.appendChild(impactedSummary); const impactedList = [topAbsolute[0], topRelative[0]].filter(Boolean); const seen = new Set(); impactedList.forEach((r) => { if (!r || !r.seasonalTrend || !r.seasonalTrend.filtered) return; if (seen.has(r.eventName)) return; seen.add(r.eventName); const card = document.createElement('div'); card.style.marginTop = '12px'; card.style.padding = '10px'; card.style.backgroundColor = '#3a3250'; card.style.borderRadius = '4px'; const title = document.createElement('div'); title.style.color = STYLES.textColor; title.style.fontWeight = 'bold'; title.style.marginBottom = '6px'; title.textContent = r.displayName || r.eventName; card.appendChild(title); const stats = document.createElement('div'); stats.style.color = STYLES.textColor; stats.style.fontSize = '12px'; stats.style.display = 'grid'; stats.style.gridTemplateColumns = 'repeat(auto-fit, minmax(160px, 1fr))'; stats.style.gap = '6px'; stats.innerHTML = `
Avg finishers: ${r.seasonalTrend.baseline.avgFinishers}
Min finishers: ${r.seasonalTrend.baseline.minFinishers}
Max finishers: ${r.seasonalTrend.baseline.maxFinishers}
Avg volunteers: ${r.seasonalTrend.baseline.avgVolunteers}
Min volunteers: ${r.seasonalTrend.baseline.minVolunteers}
Max volunteers: ${r.seasonalTrend.baseline.maxVolunteers}
`; card.appendChild(stats); if ( typeof Chart !== 'undefined' && r.seasonalTrend.filtered.finishers && r.seasonalTrend.filtered.finishers.length > 0 ) { const canvas = document.createElement('canvas'); canvas.style.marginTop = '10px'; card.appendChild(canvas); // Build chart data including cancellation date const chartLabels = [...r.seasonalTrend.filtered.dates]; const chartFinishers = [...r.seasonalTrend.filtered.finishers]; const chartVolunteers = [...r.seasonalTrend.filtered.volunteers]; const cancelDateStr = dateStr; // Add cancellation date point if (r.eventOnDate) { chartLabels.push(cancelDateStr); chartFinishers.push(r.eventOnDate.finishers); chartVolunteers.push(r.eventOnDate.volunteers); } // eslint-disable-next-line no-undef new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: chartLabels, datasets: [ { label: 'Finishers', data: chartFinishers, borderColor: STYLES.lineColor, backgroundColor: 'rgba(34, 211, 238, 0.25)', tension: 0.2, fill: true, }, { label: 'Volunteers', data: chartVolunteers, borderColor: STYLES.successColor, backgroundColor: 'rgba(16, 185, 129, 0.18)', tension: 0.2, fill: true, }, { label: 'Finishers baseline avg', data: chartLabels.map(() => r.seasonalTrend.baseline.avgFinishers), borderColor: STYLES.barColor, borderDash: [6, 4], pointRadius: 0, }, { label: 'Volunteers baseline avg', data: chartLabels.map(() => r.seasonalTrend.baseline.avgVolunteers), borderColor: STYLES.gridColor, borderDash: [6, 4], pointRadius: 0, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.6, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Seasonal trend', color: STYLES.textColor }, }, scales: { x: { ticks: { color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor } }, y: { beginAtZero: true, ticks: { precision: 0, color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, title: { display: true, text: 'Count', color: STYLES.textColor }, }, }, }, }); } impactedSection.appendChild(card); }); } resultsSection.appendChild(impactedSection); // Charts section if (typeof Chart !== 'undefined') { const chartsContainer = document.createElement('div'); chartsContainer.style.marginTop = '30px'; const chartsHeading = document.createElement('h3'); chartsHeading.textContent = 'Visual Impact Analysis'; chartsHeading.style.color = STYLES.barColor; chartsHeading.style.marginBottom = '15px'; chartsContainer.appendChild(chartsHeading); const chartsGrid = document.createElement('div'); chartsGrid.style.display = 'grid'; chartsGrid.style.gridTemplateColumns = '1fr 1fr'; chartsGrid.style.gap = '20px'; // Chart 1: Finishers - Baseline vs Actual const finishersCanvas = document.createElement('canvas'); const finishersContainer = document.createElement('div'); finishersContainer.style.minWidth = '0'; finishersContainer.appendChild(finishersCanvas); chartsGrid.appendChild(finishersContainer); // Chart 2: Volunteers - Baseline vs Actual const volunteersCanvas = document.createElement('canvas'); const volunteersContainer = document.createElement('div'); volunteersContainer.style.minWidth = '0'; volunteersContainer.appendChild(volunteersCanvas); chartsGrid.appendChild(volunteersContainer); chartsContainer.appendChild(chartsGrid); resultsSection.appendChild(chartsContainer); // Render finishers chart const finishersLabels = results .filter((r) => r.eventOnDate) .map((r) => r.title || r.eventName); const finishersBaseline = results .filter((r) => r.eventOnDate) .map((r) => r.baseline.avgFinishers); const finishersActual = results .filter((r) => r.eventOnDate) .map((r) => r.eventOnDate.finishers); // eslint-disable-next-line no-undef new Chart(finishersCanvas.getContext('2d'), { type: 'bar', data: { labels: finishersLabels, datasets: [ { label: 'Baseline (12-week avg)', data: finishersBaseline, backgroundColor: STYLES.barColor, borderColor: STYLES.barColor, borderWidth: 1, }, { label: `Actual on ${dateStr}`, data: finishersActual, backgroundColor: STYLES.lineColor, borderColor: STYLES.lineColor, borderWidth: 1, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.5, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Finishers: Baseline vs Actual', color: STYLES.textColor, }, }, scales: { x: { ticks: { color: STYLES.subtleTextColor, display: false }, grid: { color: STYLES.gridColor }, }, y: { beginAtZero: true, title: { display: true, text: 'Finishers', color: STYLES.textColor }, ticks: { precision: 0, color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, }, }, }); // Render volunteers chart const volunteersBaseline = results .filter((r) => r.eventOnDate) .map((r) => r.baseline.avgVolunteers); const volunteersActual = results .filter((r) => r.eventOnDate) .map((r) => r.eventOnDate.volunteers); // eslint-disable-next-line no-undef new Chart(volunteersCanvas.getContext('2d'), { type: 'bar', data: { labels: finishersLabels, datasets: [ { label: 'Baseline (12-week avg)', data: volunteersBaseline, backgroundColor: STYLES.barColor, borderColor: STYLES.barColor, borderWidth: 1, }, { label: `Actual on ${dateStr}`, data: volunteersActual, backgroundColor: STYLES.lineColor, borderColor: STYLES.lineColor, borderWidth: 1, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.5, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Volunteers: Baseline vs Actual', color: STYLES.textColor, }, }, scales: { x: { ticks: { color: STYLES.subtleTextColor, display: false }, grid: { color: STYLES.gridColor }, }, y: { beginAtZero: true, title: { display: true, text: 'Volunteers', color: STYLES.textColor }, ticks: { precision: 0, color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, }, }, }); } // Summary statistics const summary = document.createElement('div'); summary.style.marginTop = '20px'; summary.style.padding = '12px'; summary.style.backgroundColor = '#3a3250'; summary.style.borderRadius = '4px'; const summaryHeading = document.createElement('h4'); summaryHeading.textContent = 'Summary'; summaryHeading.style.margin = '0 0 8px 0'; summaryHeading.style.color = STYLES.lineColor; summary.appendChild(summaryHeading); const eventsWithData = results.filter((r) => r.eventOnDate && r.change); const totalGain = eventsWithData.reduce((sum, r) => sum + r.change.finishersChange, 0); const totalVolunteerGain = eventsWithData.reduce( (sum, r) => sum + r.change.volunteersChange, 0 ); const avgChangeFinishers = eventsWithData.length > 0 ? Math.round(totalGain / eventsWithData.length) : 0; const avgChangeVolunteers = eventsWithData.length > 0 ? Math.round( eventsWithData.reduce((sum, r) => sum + r.change.volunteersChange, 0) / eventsWithData.length ) : 0; const summaryText = document.createElement('div'); summaryText.style.fontSize = '13px'; summaryText.style.color = STYLES.textColor; summaryText.innerHTML = `

${results.length} nearby parkruns analyzed

${eventsWithData.length} held events on ${dateStr}

Average change in finishers: ${avgChangeFinishers > 0 ? '+' : ''}${avgChangeFinishers}

Average change in volunteers: ${avgChangeVolunteers > 0 ? '+' : ''}${avgChangeVolunteers}

Estimated total additional finishers: ${totalGain > 0 ? '+' : ''}${totalGain}

Estimated total additional volunteers: ${totalVolunteerGain > 0 ? '+' : ''}${totalVolunteerGain}

`; summary.appendChild(summaryText); resultsSection.appendChild(summary); // Download button const downloadContainer = document.createElement('div'); downloadContainer.style.display = 'flex'; downloadContainer.style.justifyContent = 'center'; downloadContainer.style.marginTop = '20px'; downloadContainer.style.gap = '10px'; downloadContainer.style.flexWrap = 'wrap'; const exportHtmlBtn = document.createElement('button'); exportHtmlBtn.textContent = '📄 Export HTML'; exportHtmlBtn.style.padding = '8px 16px'; exportHtmlBtn.style.backgroundColor = STYLES.barColor; exportHtmlBtn.style.color = '#1c1b2a'; exportHtmlBtn.style.border = 'none'; exportHtmlBtn.style.borderRadius = '4px'; exportHtmlBtn.style.cursor = 'pointer'; exportHtmlBtn.style.fontWeight = 'bold'; exportHtmlBtn.style.fontSize = '14px'; const getReportMeta = () => { const eventInfo = getCurrentEventInfo(); const currentParkrun = state.allParkruns.find( (p) => p.properties.eventname === eventInfo.eventName ); const eventShortName = currentParkrun?.properties?.EventShortName || eventInfo.eventName; const cancellationDateStr = currentDate.toISOString().split('T')[0]; return { eventShortName, cancellationDateStr }; }; exportHtmlBtn.addEventListener('click', async () => { const originalLabel = exportHtmlBtn.textContent; const originalDisplay = exportHtmlBtn.style.display; exportHtmlBtn.textContent = 'Exporting...'; exportHtmlBtn.disabled = true; exportHtmlBtn.style.display = 'none'; try { const { eventShortName, cancellationDateStr } = getReportMeta(); const { blob, filename } = await generateReportBlob(resultsSection, { eventShortName, cancellationDateStr, generatedAt: new Date().toLocaleString(), }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); console.log('HTML export complete'); } catch (error) { console.error('HTML export failed:', error); alert('Error exporting HTML: ' + error.message); } finally { exportHtmlBtn.disabled = false; exportHtmlBtn.textContent = originalLabel; exportHtmlBtn.style.display = originalDisplay; } }); const shareBtn = document.createElement('button'); shareBtn.textContent = '📤 Share Report'; shareBtn.style.padding = '8px 16px'; shareBtn.style.backgroundColor = STYLES.lineColor; shareBtn.style.color = '#2b223d'; shareBtn.style.border = 'none'; shareBtn.style.borderRadius = '4px'; shareBtn.style.cursor = 'pointer'; shareBtn.style.fontWeight = 'bold'; shareBtn.style.fontSize = '14px'; shareBtn.addEventListener('click', async () => { const originalLabel = shareBtn.textContent; const originalDisplay = shareBtn.style.display; shareBtn.textContent = 'Sharing...'; shareBtn.disabled = true; shareBtn.style.display = 'none'; try { const { eventShortName, cancellationDateStr } = getReportMeta(); const { blob, filename } = await generateReportBlob(resultsSection, { eventShortName, cancellationDateStr, generatedAt: new Date().toLocaleString(), }); const file = new File([blob], filename, { type: 'text/html' }); if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ title: `parkrun Cancellation Impact - ${eventShortName}`, text: `Cancellation date: ${cancellationDateStr}`, files: [file], }); console.log('Report shared via Web Share API'); } else { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); alert( 'Sharing is not supported in this browser, so the HTML report was downloaded instead.' ); } } catch (error) { console.error('Share failed:', error); alert('Error sharing report: ' + error.message); } finally { shareBtn.disabled = false; shareBtn.textContent = originalLabel; shareBtn.style.display = originalDisplay; } }); downloadContainer.appendChild(exportHtmlBtn); downloadContainer.appendChild(shareBtn); resultsSection.appendChild(downloadContainer); resultsContainer.appendChild(resultsSection); } async function init() { const resultsTable = document.querySelector('.Results-table'); const pageUrl = window.location.href; const isEventHistoryPage = pageUrl.includes('/eventhistory/'); if (!resultsTable || !isEventHistoryPage) { return; } state.allParkruns = await fetchAllParkruns(); renderCancellationAnalysis(); } init(); })();