// ==UserScript==
// @name parkrun Wilson index display
// @description The "Wilson index" in parkrun is the highest consecutive event number completed, starting from #1. This script calculates and displays a parkrunner's Wilson index on their results page.
// @author Pete Johns (@johnsyweb)
// @grant none
// @homepage https://github.com/johnsyweb/tampermonkey-parkrun
// @icon https://www.google.com/s2/favicons?sz=64&domain=parkrun.com.au
// @license MIT
// @match *://www.parkrun.ca/parkrunner/*/all/
// @match *://www.parkrun.co.at/parkrunner/*/all/
// @match *://www.parkrun.co.nl/parkrunner/*/all/
// @match *://www.parkrun.co.nz/parkrunner/*/all/
// @match *://www.parkrun.co.za/parkrunner/*/all/
// @match *://www.parkrun.com.au/parkrunner/*/all/
// @match *://www.parkrun.com.de/parkrunner/*/all/
// @match *://www.parkrun.dk/parkrunner/*/all/
// @match *://www.parkrun.fi/parkrunner/*/all/
// @match *://www.parkrun.fr/parkrunner/*/all/
// @match *://www.parkrun.ie/parkrunner/*/all/
// @match *://www.parkrun.it/parkrunner/*/all/
// @match *://www.parkrun.jp/parkrunner/*/all/
// @match *://www.parkrun.lt/parkrunner/*/all/
// @match *://www.parkrun.my/parkrunner/*/all/
// @match *://www.parkrun.no/parkrunner/*/all/
// @match *://www.parkrun.org.uk/parkrunner/*/all/
// @match *://www.parkrun.pl/parkrunner/*/all/
// @match *://www.parkrun.se/parkrunner/*/all/
// @match *://www.parkrun.sg/parkrunner/*/all/
// @match *://www.parkrun.us/parkrunner/*/all/
// @namespace http://tampermonkey.net/
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @run-at document-end
// @supportURL https://github.com/johnsyweb/tampermonkey-parkrun/issues/
// @tag parkrun
// @version 2025-06-02 02:17 01:28 02:13 19:55 10:42
// ==/UserScript==
(function () {
'use strict';
/**
* Finds the last results table with a specified number of columns
* @param {Document} document - The document object to search in
* @param {number} [columnCount=7] - Required number of columns
* @returns {HTMLTableElement|null} The matching table or null if not found
*/
function findResultsTable(document, columnCount = 7) {
const tables = document.querySelectorAll('[id="results"]');
let matchingTable = null;
for (const table of tables) {
const firstRow = table.querySelector('tr');
if (firstRow) {
const columns = firstRow.querySelectorAll('th, td').length;
if (columns === columnCount) {
matchingTable = table;
break;
}
}
}
return matchingTable;
}
function extractEventDetails(table) {
const rows = Array.from(table.querySelectorAll('tbody > tr'));
return rows.reverse().map((row) => {
const eventName = row.querySelector('td:nth-child(1)').textContent.trim();
const eventDate = row.querySelector('td:nth-child(2)').textContent.trim();
const eventNumber = row.querySelector('td:nth-child(3)').textContent.trim();
return {
eventName,
eventDate,
eventNumber: parseInt(eventNumber, 10),
};
});
}
/**
* Calculates the Wilson index, which represents the highest consecutive number of parkrun events
* completed starting from 1. It iterates through the sorted event numbers and increments the index
* as long as the next event number matches the expected value.
*
* @param {Array} events - An array of event objects containing event numbers.
* @returns {number} The calculated Wilson index.
*/
function calculateWilsonIndex(events) {
let wilsonIndex = 0;
const eventNumbers = events.map((e) => e.eventNumber).sort((a, b) => a - b);
for (const eventNumber of eventNumbers) {
if (eventNumber >= wilsonIndex + 2) {
break;
} else if (eventNumber === wilsonIndex + 1) {
wilsonIndex++;
}
}
return wilsonIndex;
}
function calculateWilsonIndexOverTime(events) {
const wilsonIndices = [];
for (let i = 0; i < events.length; i++) {
const subset = events.slice(0, i + 1);
const parkruns = i + 1;
const event = `${events[i].eventName} # ${events[i].eventNumber} on ${events[i].eventDate}`;
const wilsonIndex = calculateWilsonIndex(subset);
wilsonIndices.push({ parkruns, event, wilsonIndex });
}
return wilsonIndices;
}
function createWilsonGraph(indices, container, athleteInfo) {
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '300px';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
// eslint-disable-next-line no-undef
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: indices.map((i) => i.parkruns),
datasets: [
{
label: athleteInfo,
data: indices.map((i) => ({
x: i.parkruns,
y: i.wilsonIndex,
event: i.event,
})),
borderColor: getDatasetColor(0),
backgroundColor: '#2b223d',
},
],
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Wilson Index',
},
suggestedMax: Math.ceil(Math.max(...indices.map((i) => i.wilsonIndex)) * 1.1), // Add 10% padding
},
x: {
title: {
display: true,
text: 'parkruns',
},
min: 0,
suggestedMax: Math.ceil(indices.length * 1.1), // Initial padding
},
},
plugins: {
title: {
display: true,
text: 'Wilson Index Progress',
},
tooltip: {
callbacks: {
label: function (context) {
const point = context.raw;
return [`Wilson Index: ${point.y}`, `Event: ${point.event}`];
},
},
},
},
},
});
return chart;
}
/**
* Fetches text content from a URI with caching support
* @param {string} uri - The URI to fetch from
* @param {string} cacheKey - The key to use for caching
* @param {number} [cacheTtlMs=3600000] - Cache TTL in milliseconds (default: 1 hour)
* @returns {Promise<string>} - The fetched text content
*/
async function fetchWithCache(uri, cacheKey, cacheTtlMs = 60 * 60 * 1000) {
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
const isFresh = Date.now() - timestamp < cacheTtlMs;
if (isFresh) {
return data;
}
}
return fetch(uri)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
return response.text();
})
.then((text) => {
sessionStorage.setItem(
cacheKey,
JSON.stringify({
data: text,
timestamp: Date.now(),
})
);
return text;
})
.catch((error) => {
console.error(`Error fetching ${uri}:`, error);
if (cached) {
console.warn('Using stale cached data after fetch failure');
return JSON.parse(cached).data;
}
throw error;
});
}
async function fetchFriendResults(athleteId) {
const cacheKey = `parkrunner_${athleteId}_all`;
const uri = `${window.location.origin}/parkrunner/${athleteId}/all/`;
const text = await fetchWithCache(uri, cacheKey);
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const table = findResultsTable(doc);
if (!table) {
console.error('Friend results table not found');
return null;
}
const h2Element = doc.querySelector('h2');
if (!h2Element) {
console.error('Friend H2 element not found');
return null;
}
const friendInfo = extractAthleteInfo(h2Element);
if (!friendInfo) {
console.error('Could not extract friend athlete info');
return null;
}
const friendEvents = extractEventDetails(table);
const friendIndices = calculateWilsonIndexOverTime(friendEvents);
return { friendIndices, friendInfo };
}
function createComparisonUI(container, onCompare) {
const form = document.createElement('form');
form.style.marginBottom = '20px';
form.style.textAlign = 'center';
const input = document.createElement('input');
input.style.width = '200px';
input.type = 'text';
input.placeholder = "Enter friend's athlete ID (e.g. A507)";
input.style.padding = '5px';
input.style.marginRight = '10px';
input.style.borderRadius = '3px';
input.style.border = '1px solid #ffa300';
input.style.backgroundColor = '#2b223d';
input.style.color = '#ffa300';
const button = document.createElement('button');
button.textContent = 'Compare';
button.style.padding = '5px 10px';
button.style.backgroundColor = '#ffa300';
button.style.color = '#2b223d';
button.style.border = 'none';
button.style.borderRadius = '3px';
button.style.cursor = 'pointer';
form.appendChild(input);
form.appendChild(button);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const athleteId = input.value.trim().replace(/^[aA]/, '');
if (!athleteId) return;
button.disabled = true;
button.textContent = 'Loading...';
try {
const { friendIndices, friendInfo } = await fetchFriendResults(athleteId);
onCompare(friendIndices, friendInfo);
} catch (error) {
console.error("Failed to fetch friend's results:", error);
alert("Failed to fetch friend's results. Please check the ID and try again.");
} finally {
button.disabled = false;
button.textContent = 'Compare';
}
});
container.insertBefore(form, container.firstChild);
}
function updateChart(chart, friendIndices, friendInfo) {
const datasetIndex = chart.data.datasets.length;
const friendDataset = {
label: friendInfo,
data: friendIndices.map((i) => ({
x: i.parkruns,
y: i.wilsonIndex,
event: i.event,
})),
borderColor: getDatasetColor(datasetIndex),
backgroundColor: '#2b223d',
};
chart.data.datasets.push(friendDataset);
chart.update();
const maxParkruns = Math.max(
...chart.data.datasets.flatMap((dataset) => dataset.data.map((d) => d.x))
);
const maxWilsonIndex = Math.max(
...chart.data.datasets.flatMap((dataset) => dataset.data.map((d) => d.y))
);
chart.options.scales.x.suggestedMax = Math.ceil(maxParkruns * 1.1);
chart.options.scales.y.suggestedMax = Math.ceil(maxWilsonIndex * 1.1);
chart.update();
}
function extractAthleteInfo(h2Element) {
return h2Element.textContent.trim();
}
function getDatasetColor(index) {
const colors = [
'#FFA300',
'#90EE90',
'#FF69B4',
'#4169E1',
'#FFD700',
'#9370DB',
'#20B2AA',
'#FF6347',
'#DDA0DD',
'#00CED1',
];
return colors[index % colors.length];
}
function displayWilsonIndex() {
const table = findResultsTable(document);
if (!table) {
console.error('Results table not found');
return;
}
const h2Element = document.querySelector('h2');
if (!h2Element) {
console.error('H2 element not found');
return;
}
const athleteInfo = extractAthleteInfo(h2Element);
if (!athleteInfo) {
console.error('Could not extract athlete info');
return;
}
const eventDetails = extractEventDetails(table);
const wilsonIndex = calculateWilsonIndex(eventDetails);
const wilsonIndices = calculateWilsonIndexOverTime(eventDetails);
if (h2Element) {
const container = document.createElement('div');
container.style.marginTop = '20px';
container.style.backgroundColor = '#2b223d';
container.style.padding = '20px';
container.style.borderRadius = '5px';
const wilsonElement = document.createElement('div');
wilsonElement.textContent = `Wilson index: ${wilsonIndex}`;
wilsonElement.style.fontSize = '1.5em';
wilsonElement.style.color = '#ffa300';
wilsonElement.style.fontWeight = 'bold';
wilsonElement.style.marginBottom = '20px';
wilsonElement.style.textAlign = 'center';
container.appendChild(wilsonElement);
const chartInstance = createWilsonGraph(wilsonIndices, container, athleteInfo);
createComparisonUI(container, async (friendIndices, friendInfo) => {
updateChart(chartInstance, friendIndices, friendInfo);
});
h2Element.parentNode.insertBefore(container, h2Element.nextSibling);
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
calculateWilsonIndex,
calculateWilsonIndexOverTime,
extractEventDetails,
findResultsTable,
};
} else {
displayWilsonIndex();
}
})();