// ==UserScript== // @name parkrun Annual Summary // @description Adds an annual participation summary (totals, averages, min/max) to parkrun event history pages // @author Pete Johns (@johnsyweb) // @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-annual-summary.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-annual-summary.user.js // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js // @version 0.2.6 // ==/UserScript== (function () { 'use strict'; const STYLES = { backgroundColor: '#1c1b2a', barColor: '#f59e0b', // amber 500 lineColor: '#22d3ee', // cyan 400 textColor: '#f3f4f6', subtleTextColor: '#d1d5db', gridColor: 'rgba(243, 244, 246, 0.18)', }; const COMPARISON_COLORS = [ '#f59e0b', // amber '#22d3ee', // cyan '#f97316', // orange '#10b981', // emerald '#a855f7', // purple '#ef4444', // red '#3b82f6', // blue '#84cc16', // lime ]; // Global state for comparison const state = { currentEvent: null, comparisonEvents: [], allParkruns: null, }; 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'; const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours try { // Check for cached data 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'); console.log('Fetch response status:', response.status, response.statusText); if (!response.ok) { console.error('Fetch failed with status:', response.status); return []; } const data = await response.json(); // The events.json structure has events under data.events.features const features = data.events?.features || data.features || []; console.log('Features array length:', features.length); if (!features || features.length === 0) { console.error('No features found in response data'); return []; } // Cache the features with timestamp try { localStorage.setItem( CACHE_KEY, JSON.stringify({ data: features, timestamp: Date.now(), }) ); console.log('Cached', features.length, 'parkrun events for 24 hours'); } 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); console.error('Error details:', error.message, error.stack); 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; // Earth's radius in km 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); // Quick bounding box filter (~0.5 degrees ≈ 55km) 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); } async function buildHtmlReport(mainContainer, meta) { const clone = mainContainer.cloneNode(true); const originalCanvases = mainContainer.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-annual-summary { 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 Annual Summary

Event: ${meta.eventShortName}
Generated: ${meta.generatedAt}
`; return `parkrun Annual Summary - ${meta.eventShortName}${header}${clone.outerHTML}`; } async function generateReportBlob(mainContainer, meta) { const html = await buildHtmlReport(mainContainer, meta); const filename = `parkrun-annual-summary-${meta.eventShortName}-${meta.generatedAtISO}.html`; return { blob: new Blob([html], { type: 'text/html' }), filename, }; } async function fetchEventHistory(eventName, domain) { try { 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} Event History`; const eventNumbers = []; const dates = []; 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) { 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 { eventName, title, eventNumbers, dates, finishers, volunteers, }; } catch (error) { console.error(`Failed to fetch event history for ${eventName}:`, error); return null; } } function extractEventHistoryData() { const title = document.querySelector('h1')?.textContent.trim() ?? 'Event History'; const eventNumbers = []; const dates = []; 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) { 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, finishers, volunteers, }; } function aggregateByYear(historyData) { const yearly = {}; historyData.dates.forEach((dateStr, index) => { const date = new Date(dateStr); const year = date.getFullYear(); const finishers = historyData.finishers[index] ?? 0; const volunteers = historyData.volunteers[index] ?? 0; const eventNumber = historyData.eventNumbers[index]; if (!yearly[year]) { yearly[year] = { year, eventCount: 0, totalFinishers: 0, totalVolunteers: 0, minFinishers: null, maxFinishers: null, minVolunteers: null, maxVolunteers: null, }; } const current = yearly[year]; current.eventCount++; current.totalFinishers += finishers; current.totalVolunteers += volunteers; if (current.minFinishers === null || finishers < current.minFinishers.value) { current.minFinishers = { value: finishers, date: historyData.dates[index], eventNumber }; } if (current.maxFinishers === null || finishers > current.maxFinishers.value) { current.maxFinishers = { value: finishers, date: historyData.dates[index], eventNumber }; } if (current.minVolunteers === null || volunteers < current.minVolunteers.value) { current.minVolunteers = { value: volunteers, date: historyData.dates[index], eventNumber }; } if (current.maxVolunteers === null || volunteers > current.maxVolunteers.value) { current.maxVolunteers = { value: volunteers, date: historyData.dates[index], eventNumber }; } }); return Object.keys(yearly) .map(Number) .sort((a, b) => a - b) .map((year) => { const data = yearly[year]; return { year, eventCount: data.eventCount, totalFinishers: data.totalFinishers, totalVolunteers: data.totalVolunteers, avgFinishers: Math.round(data.totalFinishers / data.eventCount), avgVolunteers: Math.round(data.totalVolunteers / data.eventCount), minFinishers: data.minFinishers, maxFinishers: data.maxFinishers, minVolunteers: data.minVolunteers, maxVolunteers: data.maxVolunteers, finishersGrowth: null, volunteersGrowth: null, }; }) .map((row, index, arr) => { if (index === 0) { return row; } const prev = arr[index - 1]; const finishersGrowth = prev.avgFinishers ? ((row.avgFinishers - prev.avgFinishers) / prev.avgFinishers) * 100 : null; const volunteersGrowth = prev.avgVolunteers ? ((row.avgVolunteers - prev.avgVolunteers) / prev.avgVolunteers) * 100 : null; return { ...row, finishersGrowth, volunteersGrowth, }; }); } function formatExtrema(record) { if (!record) return '-'; return record.value.toLocaleString(); } function formatGrowth(growth) { if (growth === null || Number.isNaN(growth)) return '-'; const sign = growth > 0 ? '+' : ''; const color = growth > 0 ? '#53BA9D' : growth < 0 ? '#ff6b6b' : STYLES.subtleTextColor; return `${sign}${growth.toFixed(1)}%`; } function createComparisonSelector(nearbyParkruns) { const selectorContainer = document.createElement('div'); selectorContainer.style.marginBottom = '15px'; selectorContainer.style.padding = '10px'; selectorContainer.style.backgroundColor = STYLES.backgroundColor; selectorContainer.style.borderRadius = '6px'; selectorContainer.style.display = 'flex'; selectorContainer.style.alignItems = 'center'; selectorContainer.style.gap = '10px'; selectorContainer.style.flexWrap = 'wrap'; const label = document.createElement('span'); label.textContent = 'Compare with:'; label.style.color = STYLES.textColor; label.style.fontWeight = 'bold'; selectorContainer.appendChild(label); const select = document.createElement('select'); select.style.padding = '6px 12px'; select.style.backgroundColor = '#3a3250'; select.style.color = STYLES.textColor; select.style.border = `1px solid ${STYLES.gridColor}`; select.style.borderRadius = '4px'; select.style.cursor = 'pointer'; const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.textContent = '-- Select parkrun --'; select.appendChild(defaultOption); nearbyParkruns.forEach((parkrun) => { const option = document.createElement('option'); option.value = parkrun.properties.eventname; option.textContent = `${parkrun.properties.EventShortName} (${parkrun.distance.toFixed(1)}km)`; select.appendChild(option); }); const addButton = document.createElement('button'); addButton.textContent = '+ Add'; addButton.style.padding = '6px 12px'; addButton.style.backgroundColor = STYLES.lineColor; addButton.style.color = '#2b223d'; addButton.style.border = 'none'; addButton.style.borderRadius = '4px'; addButton.style.cursor = 'pointer'; addButton.style.fontWeight = 'bold'; addButton.addEventListener('click', async () => { const selectedEventName = select.value; if (!selectedEventName) return; // Check if already added if (state.comparisonEvents.some((event) => event.eventName === selectedEventName)) { alert('This parkrun is already selected for comparison'); return; } addButton.disabled = true; addButton.textContent = 'Loading...'; const eventInfo = getCurrentEventInfo(); const historyData = await fetchEventHistory(selectedEventName, eventInfo.url); if (historyData) { const parkrunInfo = nearbyParkruns.find( (p) => p.properties.eventname === selectedEventName ); state.comparisonEvents.push({ ...historyData, distance: parkrunInfo?.distance, }); renderAllSummaries(); } else { alert('Failed to fetch event history'); } addButton.disabled = false; addButton.textContent = '+ Add'; select.value = ''; }); selectorContainer.appendChild(select); selectorContainer.appendChild(addButton); return selectorContainer; } function createSelectedEventsDisplay() { const container = document.createElement('div'); container.id = 'selectedEventsDisplay'; container.style.display = 'flex'; container.style.gap = '8px'; container.style.flexWrap = 'wrap'; container.style.marginTop = '10px'; const updateDisplay = () => { container.innerHTML = ''; [state.currentEvent, ...state.comparisonEvents].forEach((event, index) => { const chip = document.createElement('div'); chip.style.display = 'inline-flex'; chip.style.alignItems = 'center'; chip.style.gap = '6px'; chip.style.padding = '4px 10px'; chip.style.backgroundColor = COMPARISON_COLORS[index % COMPARISON_COLORS.length]; chip.style.color = '#2b223d'; chip.style.borderRadius = '12px'; chip.style.fontSize = '12px'; chip.style.fontWeight = 'bold'; const label = document.createElement('span'); label.textContent = event.title || event.eventName; chip.appendChild(label); if (index > 0) { const removeBtn = document.createElement('span'); removeBtn.textContent = '×'; removeBtn.style.cursor = 'pointer'; removeBtn.style.marginLeft = '4px'; removeBtn.style.fontSize = '16px'; removeBtn.addEventListener('click', () => { state.comparisonEvents.splice(index - 1, 1); renderAllSummaries(); }); chip.appendChild(removeBtn); } container.appendChild(chip); }); }; updateDisplay(); return { container, updateDisplay }; } function createTabsForEvents(events) { const tabsContainer = document.createElement('div'); tabsContainer.id = 'eventTabs'; tabsContainer.style.marginTop = '15px'; const tabButtons = document.createElement('div'); tabButtons.style.display = 'flex'; tabButtons.style.gap = '5px'; tabButtons.style.borderBottom = `2px solid ${STYLES.gridColor}`; tabButtons.style.marginBottom = '15px'; const tabContents = document.createElement('div'); tabContents.id = 'tabContents'; events.forEach((event, index) => { const button = document.createElement('button'); button.textContent = event.title || event.eventName; button.style.padding = '10px 20px'; button.style.backgroundColor = index === 0 ? COMPARISON_COLORS[index % COMPARISON_COLORS.length] : '#3a3250'; button.style.color = index === 0 ? '#2b223d' : STYLES.textColor; button.style.border = 'none'; button.style.borderRadius = '6px 6px 0 0'; button.style.cursor = 'pointer'; button.style.fontWeight = 'bold'; button.dataset.index = index; button.addEventListener('click', () => { // Update button styles tabButtons.querySelectorAll('button').forEach((btn, btnIndex) => { btn.style.backgroundColor = btnIndex === index ? COMPARISON_COLORS[btnIndex % COMPARISON_COLORS.length] : '#3a3250'; btn.style.color = btnIndex === index ? '#2b223d' : STYLES.textColor; }); // Show corresponding tab content tabContents.querySelectorAll('.tab-content').forEach((content, contentIndex) => { content.style.display = contentIndex === index ? 'block' : 'none'; }); }); tabButtons.appendChild(button); }); tabsContainer.appendChild(tabButtons); tabsContainer.appendChild(tabContents); return { tabsContainer, tabContents }; } function renderEventTab(historyData, eventIndex) { const yearly = aggregateByYear(historyData); if (yearly.length === 0) { return null; } const tabContent = document.createElement('div'); tabContent.className = 'tab-content'; tabContent.style.display = eventIndex === 0 ? 'block' : 'none'; tabContent.style.backgroundColor = STYLES.backgroundColor; tabContent.style.padding = '15px'; tabContent.style.borderRadius = '6px'; const tableWrap = document.createElement('div'); tableWrap.style.overflowX = 'auto'; const table = document.createElement('table'); table.className = 'annualSummaryTable'; table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '14px'; table.style.color = STYLES.textColor; table.style.backgroundColor = STYLES.backgroundColor; const sortState = { key: 'year', dir: 'asc' }; const columns = [ { key: 'year', label: 'Year', align: 'left' }, { key: 'eventCount', label: 'Events', align: 'center' }, { key: 'totalFinishers', label: 'Finishers Total', align: 'right', color: STYLES.barColor }, { key: 'minFinishers', label: 'Finishers Min', align: 'right', color: STYLES.barColor }, { key: 'maxFinishers', label: 'Finishers Max', align: 'right', color: STYLES.barColor }, { key: 'avgFinishers', label: 'Finishers Avg', align: 'right', color: STYLES.barColor }, { key: 'finishersGrowth', label: 'Finishers YoY', align: 'right' }, { key: 'totalVolunteers', label: 'Volunteers Total', align: 'right', color: STYLES.lineColor, }, { key: 'minVolunteers', label: 'Volunteers Min', align: 'right', color: STYLES.lineColor }, { key: 'maxVolunteers', label: 'Volunteers Max', align: 'right', color: STYLES.lineColor }, { key: 'avgVolunteers', label: 'Volunteers Avg', align: 'right', color: STYLES.lineColor }, { key: 'volunteersGrowth', label: 'Volunteers YoY', align: 'right' }, ]; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); headerRow.style.borderBottom = `2px solid ${STYLES.gridColor}`; columns.forEach((col) => { const th = document.createElement('th'); th.textContent = col.label; th.style.padding = '10px'; th.style.textAlign = col.align; th.style.cursor = 'pointer'; if (col.color) th.style.color = col.color; th.addEventListener('click', () => { if (sortState.key === col.key) { sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc'; } else { sortState.key = col.key; sortState.dir = 'desc'; } renderBody(); }); headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); table.appendChild(tbody); tableWrap.appendChild(table); tabContent.appendChild(tableWrap); function renderBody() { tbody.innerHTML = ''; const sorted = [...yearly].sort((a, b) => { const key = sortState.key; const dir = sortState.dir === 'asc' ? 1 : -1; const getVal = (row) => { const val = row[key]; if (val === null || val === undefined) return -Infinity; if (typeof val === 'object' && val.value !== undefined) return val.value; return val; }; const av = getVal(a); const bv = getVal(b); if (av === bv) return 0; return av > bv ? dir : -dir; }); sorted.forEach((rowData) => { const row = document.createElement('tr'); row.style.borderBottom = `1px solid ${STYLES.gridColor}`; row.innerHTML = ` ${rowData.year} ${rowData.eventCount} ${rowData.totalFinishers.toLocaleString()} ${formatExtrema(rowData.minFinishers)} ${formatExtrema(rowData.maxFinishers)} ${rowData.avgFinishers} ${formatGrowth(rowData.finishersGrowth)} ${rowData.totalVolunteers.toLocaleString()} ${formatExtrema(rowData.minVolunteers)} ${formatExtrema(rowData.maxVolunteers)} ${rowData.avgVolunteers} ${formatGrowth(rowData.volunteersGrowth)} `; tbody.appendChild(row); }); } renderBody(); // Add charts const chartsRow = document.createElement('div'); chartsRow.style.display = 'grid'; chartsRow.style.gridTemplateColumns = '1fr 1fr'; chartsRow.style.gap = '20px'; chartsRow.style.marginTop = '20px'; const totalsChartContainer = document.createElement('div'); totalsChartContainer.style.minWidth = '0'; const totalsCanvas = document.createElement('canvas'); totalsCanvas.className = `annualTotalsChart-${eventIndex}`; totalsChartContainer.appendChild(totalsCanvas); const growthChartContainer = document.createElement('div'); growthChartContainer.style.minWidth = '0'; const growthCanvas = document.createElement('canvas'); growthCanvas.className = `annualGrowthChart-${eventIndex}`; growthChartContainer.appendChild(growthCanvas); chartsRow.appendChild(totalsChartContainer); chartsRow.appendChild(growthChartContainer); tabContent.appendChild(chartsRow); // Render charts if (typeof Chart !== 'undefined') { const totalsCtx = totalsCanvas.getContext('2d'); const growthCtx = growthCanvas.getContext('2d'); const labels = yearly.map((d) => d.year.toString()); // eslint-disable-next-line no-undef new Chart(totalsCtx, { type: 'bar', data: { labels, datasets: [ { label: 'Total Finishers', data: yearly.map((d) => d.totalFinishers), backgroundColor: STYLES.barColor, borderColor: STYLES.barColor, borderWidth: 1, }, { label: 'Total Volunteers', data: yearly.map((d) => d.totalVolunteers), backgroundColor: STYLES.lineColor, borderColor: STYLES.lineColor, borderWidth: 1, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.3, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Annual Totals', color: STYLES.textColor }, }, scales: { x: { title: { display: true, text: 'Year', color: STYLES.textColor }, ticks: { color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, y: { beginAtZero: true, title: { display: true, text: 'Participants', color: STYLES.textColor }, ticks: { precision: 0, color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, }, }, }); const growthData = yearly.filter((d) => d.finishersGrowth !== null); // eslint-disable-next-line no-undef new Chart(growthCtx, { type: 'line', data: { labels: growthData.map((d) => d.year.toString()), datasets: [ { label: 'Finishers Growth', data: growthData.map((d) => d.finishersGrowth), borderColor: STYLES.barColor, backgroundColor: STYLES.barColor, borderWidth: 2, pointRadius: 4, pointBackgroundColor: STYLES.barColor, fill: false, tension: 0.2, }, { label: 'Volunteers Growth', data: growthData.map((d) => d.volunteersGrowth), borderColor: STYLES.lineColor, backgroundColor: STYLES.lineColor, borderWidth: 2, pointRadius: 4, pointBackgroundColor: STYLES.lineColor, fill: false, tension: 0.2, }, ], }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.3, plugins: { legend: { labels: { color: STYLES.textColor } }, title: { display: true, text: 'Year-over-Year Growth (%)', color: STYLES.textColor }, }, scales: { x: { title: { display: true, text: 'Year', color: STYLES.textColor }, ticks: { color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, y: { title: { display: true, text: 'Growth (%)', color: STYLES.textColor }, ticks: { color: STYLES.subtleTextColor, callback: function (value) { return value + '%'; }, }, grid: { color: STYLES.gridColor }, }, }, }, }); } return tabContent; } function renderComparisonCharts(events) { if (events.length < 2) return null; const comparisonSection = document.createElement('div'); comparisonSection.id = 'comparisonSection'; comparisonSection.style.marginTop = '30px'; comparisonSection.style.padding = '15px'; comparisonSection.style.backgroundColor = STYLES.backgroundColor; comparisonSection.style.borderRadius = '8px'; const heading = document.createElement('h3'); heading.textContent = 'Comparison Charts'; heading.style.textAlign = 'center'; heading.style.color = STYLES.barColor; heading.style.marginBottom = '20px'; comparisonSection.appendChild(heading); // Prepare data for all events const allYearlyData = events.map((event) => ({ event, yearly: aggregateByYear(event), })); // Get all unique years across all events const allYears = new Set(); allYearlyData.forEach(({ yearly }) => { yearly.forEach((y) => allYears.add(y.year)); }); const sortedYears = Array.from(allYears).sort((a, b) => a - b); // Create 4 comparison charts const chartsGrid = document.createElement('div'); chartsGrid.style.display = 'grid'; chartsGrid.style.gridTemplateColumns = '1fr 1fr'; chartsGrid.style.gap = '20px'; // Chart 1: Annual Totals - Finishers const finishersTotalsCanvas = document.createElement('canvas'); chartsGrid.appendChild(createChartContainer(finishersTotalsCanvas)); // Chart 2: Annual Totals - Volunteers const volunteersTotalsCanvas = document.createElement('canvas'); chartsGrid.appendChild(createChartContainer(volunteersTotalsCanvas)); // Chart 3: YoY Growth - Finishers const finishersGrowthCanvas = document.createElement('canvas'); chartsGrid.appendChild(createChartContainer(finishersGrowthCanvas)); // Chart 4: YoY Growth - Volunteers const volunteersGrowthCanvas = document.createElement('canvas'); chartsGrid.appendChild(createChartContainer(volunteersGrowthCanvas)); comparisonSection.appendChild(chartsGrid); // Export/share full report buttons 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 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'; const getReportMeta = () => { const eventInfo = getCurrentEventInfo(); const currentParkrun = state.allParkruns?.find( (p) => p.properties.eventname === eventInfo.eventName ); const eventShortName = currentParkrun?.properties?.EventShortName || eventInfo.eventName; const generatedAt = new Date(); const generatedAtISO = generatedAt.toISOString().split('T')[0]; return { eventShortName, generatedAt: generatedAt.toLocaleString(), generatedAtISO }; }; 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 mainContainer = document.querySelector('.parkrun-annual-summary'); if (!mainContainer) throw new Error('Report container not found'); const meta = getReportMeta(); const { blob, filename } = await generateReportBlob(mainContainer, meta); 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('Annual summary HTML export complete'); } catch (error) { console.error('Annual summary export failed:', error); alert('Error exporting HTML: ' + error.message); } finally { exportHtmlBtn.disabled = false; exportHtmlBtn.textContent = originalLabel; exportHtmlBtn.style.display = originalDisplay; } }); 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 mainContainer = document.querySelector('.parkrun-annual-summary'); if (!mainContainer) throw new Error('Report container not found'); const meta = getReportMeta(); const { blob, filename } = await generateReportBlob(mainContainer, meta); const file = new File([blob], filename, { type: 'text/html' }); if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ title: `parkrun Annual Summary - ${meta.eventShortName}`, text: 'Annual participation summary report', files: [file], }); console.log('Annual summary 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('Annual summary 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); comparisonSection.appendChild(downloadContainer); // Render charts if Chart.js is available if (typeof Chart !== 'undefined') { renderComparisonChart( finishersTotalsCanvas, 'Annual Totals - Finishers', sortedYears, allYearlyData, (y) => y.totalFinishers, 'Finishers' ); renderComparisonChart( volunteersTotalsCanvas, 'Annual Totals - Volunteers', sortedYears, allYearlyData, (y) => y.totalVolunteers, 'Volunteers' ); renderComparisonChart( finishersGrowthCanvas, 'YoY Growth - Finishers (%)', sortedYears, allYearlyData, (y) => y.finishersGrowth, 'Growth (%)', true ); renderComparisonChart( volunteersGrowthCanvas, 'YoY Growth - Volunteers (%)', sortedYears, allYearlyData, (y) => y.volunteersGrowth, 'Growth (%)', true ); } return comparisonSection; } function createChartContainer(canvas) { const container = document.createElement('div'); container.style.minWidth = '0'; container.appendChild(canvas); return container; } function renderComparisonChart( canvas, title, years, allYearlyData, valueGetter, yAxisLabel, isGrowth = false ) { const datasets = allYearlyData.map(({ event, yearly }, index) => { const color = COMPARISON_COLORS[index % COMPARISON_COLORS.length]; const data = years.map((year) => { const yearData = yearly.find((y) => y.year === year); if (!yearData) return null; const value = valueGetter(yearData); return value !== null && value !== undefined ? value : null; }); return { label: event.title || event.eventName, data, borderColor: color, backgroundColor: color, borderWidth: 2, pointRadius: 4, pointBackgroundColor: color, fill: false, tension: 0.2, spanGaps: true, }; }); const ctx = canvas.getContext('2d'); // eslint-disable-next-line no-undef new Chart(ctx, { type: 'line', data: { labels: years.map((y) => y.toString()), datasets, }, options: { animation: false, responsive: true, maintainAspectRatio: true, aspectRatio: 1.3, plugins: { legend: { labels: { color: STYLES.textColor }, }, title: { display: true, text: title, color: STYLES.textColor, }, }, scales: { x: { title: { display: true, text: 'Year', color: STYLES.textColor }, ticks: { color: STYLES.subtleTextColor }, grid: { color: STYLES.gridColor }, }, y: { beginAtZero: !isGrowth, title: { display: true, text: yAxisLabel, color: STYLES.textColor }, ticks: { precision: 0, color: STYLES.subtleTextColor, callback: isGrowth ? (value) => value + '%' : undefined, }, grid: { color: STYLES.gridColor }, }, }, }, }); } function renderAllSummaries() { // Remove existing summary const existing = document.querySelector('.parkrun-annual-summary'); if (existing) { existing.remove(); } const historyData = extractEventHistoryData(); if (historyData.eventNumbers.length === 0) { console.log('No event history data found'); return; } // Store current event state.currentEvent = { ...historyData, eventName: getCurrentEventInfo().eventName, }; const allEvents = [state.currentEvent, ...state.comparisonEvents]; const container = document.createElement('div'); container.className = 'parkrun-annual-summary'; 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)'; const heading = document.createElement('h3'); heading.textContent = 'Annual Participation Summary'; heading.style.textAlign = 'center'; heading.style.marginBottom = '15px'; heading.style.color = STYLES.barColor; container.appendChild(heading); // Add comparison selector if we have nearby parkruns if (state.allParkruns === null) { // Still loading (shouldn't happen as init() awaits the fetch) const message = document.createElement('div'); message.style.padding = '10px'; message.style.color = STYLES.subtleTextColor; message.style.textAlign = 'center'; message.style.fontSize = '13px'; message.textContent = 'Loading parkrun events for comparison...'; container.appendChild(message); } else if (state.allParkruns.length === 0) { // Fetch failed or returned no data const message = document.createElement('div'); message.style.padding = '10px'; message.style.color = '#ff6b6b'; message.style.textAlign = 'center'; message.style.fontSize = '13px'; message.textContent = 'Failed to load parkrun events data. Check console for details.'; container.appendChild(message); console.error( 'No parkrun events loaded. Expected data from https://images.parkrun.com/events.json' ); } else { const nearbyParkruns = findNearbyParkruns(getCurrentEventInfo(), state.allParkruns); if (nearbyParkruns.length > 0) { const selector = createComparisonSelector(nearbyParkruns); selector.className = 'parkrun-comparison-selector-controls'; container.appendChild(selector); } else { // Show message if no nearby parkruns found const message = document.createElement('div'); message.style.padding = '10px'; message.style.color = STYLES.subtleTextColor; message.style.textAlign = 'center'; message.style.fontSize = '13px'; const eventInfo = getCurrentEventInfo(); message.textContent = `No nearby parkruns found for comparison (within 50km of ${eventInfo.eventName})`; container.appendChild(message); console.log( 'Current event:', eventInfo.eventName, 'Total parkruns loaded:', state.allParkruns.length ); } } // Add selected events display if (state.comparisonEvents.length > 0) { const { container: eventsDisplay } = createSelectedEventsDisplay(); container.appendChild(eventsDisplay); } // Create tabs const { tabsContainer, tabContents } = createTabsForEvents(allEvents); container.appendChild(tabsContainer); // Render each event's tab allEvents.forEach((event, index) => { const tabContent = renderEventTab(event, index); if (tabContent) { tabContents.appendChild(tabContent); } }); // Add comparison charts if multiple events if (allEvents.length > 1) { const comparisonCharts = renderComparisonCharts(allEvents); if (comparisonCharts) { container.appendChild(comparisonCharts); } } // Insert into page const eventHistoryChart = document.getElementById('eventHistoryChart'); if (eventHistoryChart && eventHistoryChart.parentElement) { eventHistoryChart.parentElement.parentNode.insertBefore( container, eventHistoryChart.parentElement.nextSibling ); } else { insertAfterFirst('h1', container); } } function renderAnnualSummary() { if (document.getElementById('annualSummaryTable') || document.getElementById('eventTabs')) { console.log('Annual summary already exists, skipping render'); return; } renderAllSummaries(); } async function init() { const resultsTable = document.querySelector('.Results-table'); const pageUrl = window.location.href; const isEventHistoryPage = pageUrl.includes('/eventhistory/'); if (!resultsTable || !isEventHistoryPage) { return; } // Fetch all parkruns for comparison state.allParkruns = await fetchAllParkruns(); renderAnnualSummary(); } init(); })();