// ==UserScript==
// @name parkrun Stopwatch Bingo
// @description Visualizes your progress on the stopwatch bingo challenge (collecting seconds 00-59)
// @author Pete Johns (@johnsyweb)
// @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/stopwatch-bingo.user.js
// @grant none
// @homepage https://www.johnsy.com/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://html2canvas.hertzen.com/dist/html2canvas.min.js
// @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/stopwatch-bingo.user.js
// @version 1.0.66
// ==/UserScript==
// DO NOT EDIT - generated from src/ by scripts/build-scripts.js
(function () {
'use strict';
var STYLES = {
backgroundColor: '#2b223d',
clockBorderColor: '#FFA300',
clockFaceColor: '#333',
completedColor: '#FFA300',
textColor: '#e0e0e0',
subtleTextColor: '#cccccc'
};
function findResultsTable() {
var tables = document.querySelectorAll('#results');
return tables[tables.length - 1];
}
function extractFinishTimes(table) {
var seconds = {};
var timeData = {};
var collectedCount = 0;
var totalParkruns = 0;
var firstCompleteEvent = null;
var dateOfCompletion = null;
var rows = table.querySelectorAll('tr');
// First collect all results since they're in reverse order
var allResults = [];
rows.forEach(function (row) {
var cells = row.querySelectorAll('td');
if (cells.length < 5) return;
var eventName = cells[0].textContent.trim();
var date = cells[1].textContent.trim();
var eventNumber = cells[2].textContent.trim();
var time = cells[4].textContent.trim();
if (!time || !eventNumber || !date || !eventName) return;
var event = "".concat(eventName, " # ").concat(eventNumber);
// Parse seconds from time (either MM:SS or HH:MM:SS format)
var secondValue;
var hourMatch = time.match(/(\d+):(\d+):(\d+)/);
if (hourMatch) {
secondValue = parseInt(hourMatch[3], 10);
} else {
var minuteMatch = time.match(/(\d+):(\d+)/);
if (!minuteMatch) return;
secondValue = parseInt(minuteMatch[2], 10);
}
allResults.push({
secondValue: secondValue,
date: date,
event: event,
time: time
});
});
// Process in chronological order (reverse the array)
var reversedResults = allResults.reverse();
for (var i = 0; i < reversedResults.length; i++) {
var result = reversedResults[i];
var secondValue = result.secondValue,
date = result.date,
event = result.event,
time = result.time;
totalParkruns++;
// Only store if we haven't seen this second yet
if (!(secondValue in seconds)) {
seconds[secondValue] = {
date: date,
event: event,
time: time
};
collectedCount++;
// Check if we've completed the bingo
if (collectedCount === 60 && !dateOfCompletion) {
dateOfCompletion = date;
firstCompleteEvent = event;
timeData[secondValue] = [{
date: date,
event: event,
time: time
}];
break;
}
}
// Store all occurrences for tooltip data
if (!timeData[secondValue]) {
timeData[secondValue] = [];
}
timeData[secondValue].push({
date: date,
event: event,
time: time
});
}
return {
seconds: seconds,
timeData: timeData,
collectedCount: collectedCount,
totalParkruns: totalParkruns,
dateOfCompletion: dateOfCompletion,
firstCompleteEvent: firstCompleteEvent
};
}
function createClockContainer(title) {
var container = document.createElement('div');
if (!container.id) container.id = 'stopwatchBingoContainer';
container.className = 'parkrun-bingo-container';
container.style.width = '100%';
container.style.maxWidth = '800px';
container.style.margin = '20px auto';
container.style.padding = window.innerWidth < 768 ? '10px' : '20px';
container.style.backgroundColor = STYLES.backgroundColor;
container.style.borderRadius = '8px';
container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
container.style.textAlign = 'center';
var heading = document.createElement('h3');
heading.textContent = title;
heading.style.marginBottom = '15px';
heading.style.color = STYLES.clockBorderColor;
heading.style.fontSize = window.innerWidth < 768 ? '1.1em' : '1.3em';
container.appendChild(heading);
return container;
}
function createBingoClock(data) {
var container = createClockContainer('Stopwatch Bingo Challenge');
// Calculate responsive clock size (smaller on mobile)
var isMobile = window.innerWidth < 768;
// Add stats
var statsContainer = document.createElement('div');
statsContainer.style.marginBottom = isMobile ? '10px' : '20px';
statsContainer.style.color = STYLES.textColor;
var statsFontSize = isMobile ? '1em' : '1.2em';
var completionFontSize = isMobile ? '0.95em' : '1.1em';
var statsText = "
").concat(data.collectedCount, " of 60 seconds collected
");
statsText += "After ").concat(data.totalParkruns, " parkruns
");
if (data.dateOfCompletion) {
statsText += "\uD83C\uDFC6 Bingo completed on ").concat(data.dateOfCompletion, " (").concat(data.firstCompleteEvent, ")
");
}
statsContainer.innerHTML = statsText;
container.appendChild(statsContainer);
// Calculate responsive clock size (smaller on mobile)
var baseSize = isMobile ? Math.min(320, window.innerWidth - 40) : 500;
var scale = baseSize / 500; // Scale factor for all dimensions
// Create clock face
var clockContainer = document.createElement('div');
clockContainer.style.position = 'relative';
clockContainer.style.width = "".concat(baseSize, "px");
clockContainer.style.height = "".concat(baseSize, "px");
clockContainer.style.margin = '0 auto';
clockContainer.style.borderRadius = '50%';
clockContainer.style.border = "".concat(Math.round(10 * scale), "px solid ").concat(STYLES.clockBorderColor);
clockContainer.style.backgroundColor = STYLES.clockFaceColor;
clockContainer.style.boxSizing = 'content-box';
// Determine maximum frequency to scale segment lengths
var maxOccurrences = 1;
for (var i = 0; i < 60; i++) {
var occ = data.timeData[i] ? data.timeData[i].length : 0;
if (occ > maxOccurrences) maxOccurrences = occ;
}
// Add the second segments first (so they're at the bottom layer)
var _loop = function _loop(_i) {
var hasSecond = _i in data.seconds;
if (!hasSecond) return 1; // continue
// Only render segments for collected seconds
var occurrences = data.timeData[_i] ? data.timeData[_i].length : 0;
// Calculate angles for this segment (0 seconds at top, moving clockwise)
var startAngle = ((_i - 0.4) / 60 * 360 - 90) * (Math.PI / 180);
var endAngle = ((_i + 0.4) / 60 * 360 - 90) * (Math.PI / 180);
// Create a segment using SVG path
var segment = document.createElement('div');
segment.style.position = 'absolute';
segment.style.top = '0';
segment.style.left = '0';
segment.style.width = '100%';
segment.style.height = '100%';
segment.style.pointerEvents = 'none'; // Make it non-blocking for clicks
// Base radii (scaled for responsive sizing)
var maxRadius = 220 * scale; // Leave space for indices
var innerHoleRadius = 50 * scale; // Matches the visual centre (100px diameter)
var centerX = baseSize / 2;
var centerY = baseSize / 2;
// Create SVG element for the segment
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.zIndex = '1'; // Lower z-index to keep below indices
// Create the path for the pie slice
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// Calculate the points for the path as a ring sector clearing the centre
// Vary the outer radius proportional to the number of occurrences
var ringSpan = maxRadius - innerHoleRadius;
var outerRadius = innerHoleRadius + Math.max(0, Math.round(occurrences / maxOccurrences * ringSpan));
var innerStartX = centerX + innerHoleRadius * Math.cos(startAngle);
var innerStartY = centerY + innerHoleRadius * Math.sin(startAngle);
var outerStartX = centerX + outerRadius * Math.cos(startAngle);
var outerStartY = centerY + outerRadius * Math.sin(startAngle);
var outerEndX = centerX + outerRadius * Math.cos(endAngle);
var outerEndY = centerY + outerRadius * Math.sin(endAngle);
var innerEndX = centerX + innerHoleRadius * Math.cos(endAngle);
var innerEndY = centerY + innerHoleRadius * Math.sin(endAngle);
// Path goes from inner arc start to outer arc, around, then back along inner arc to close
var pathData = ["M ".concat(innerStartX, ",").concat(innerStartY), "L ".concat(outerStartX, ",").concat(outerStartY), "A ".concat(outerRadius, ",").concat(outerRadius, " 0 0,1 ").concat(outerEndX, ",").concat(outerEndY), "L ".concat(innerEndX, ",").concat(innerEndY), "A ".concat(innerHoleRadius, ",").concat(innerHoleRadius, " 0 0,0 ").concat(innerStartX, ",").concat(innerStartY), 'Z'].join(' ');
path.setAttribute('d', pathData);
path.setAttribute('fill', STYLES.completedColor);
path.setAttribute('opacity', '1');
// Update title to show tooltip on hover
path.setAttribute('title', "".concat(_i.toString().padStart(2, '0'), " Seconds - ").concat(occurrences, " ").concat(occurrences === 1 ? 'Time' : 'Times'));
// Add click handler directly to the path for interaction
path.style.cursor = 'pointer';
path.style.pointerEvents = 'auto'; // Make path clickable
path.addEventListener('click', function () {
showSecondDetails(_i, data.timeData[_i]);
});
svg.appendChild(path);
segment.appendChild(svg);
clockContainer.appendChild(segment);
};
for (var _i = 0; _i < 60; _i++) {
if (_loop(_i)) continue;
}
// Add stopwatch indices (markers at 5-second intervals) OVER the segments
var indexSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
indexSvg.setAttribute('width', '100%');
indexSvg.setAttribute('height', '100%');
indexSvg.style.position = 'absolute';
indexSvg.style.top = '0';
indexSvg.style.left = '0';
indexSvg.style.pointerEvents = 'none';
indexSvg.style.zIndex = '2'; // Higher z-index to keep above segments
// Create indices at 5-second intervals (0, 5, 10, etc.)
for (var _i2 = 0; _i2 < 60; _i2 += 5) {
// Calculate angle for this index
var angle = _i2 / 60 * 360 - 90; // -90 to start at top
var radians = angle * (Math.PI / 180);
// Calculate position (outer edge of the clock)
var indexOuterRadius = 245 * scale; // Just inside the border
var indexInnerRadius = (_i2 % 15 === 0 ? 210 : 225) * scale; // Longer marks for 0, 15, 30, 45
var centerX = baseSize / 2;
var centerY = baseSize / 2;
// Create line for index mark
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', centerX + indexInnerRadius * Math.cos(radians));
line.setAttribute('y1', centerY + indexInnerRadius * Math.sin(radians));
line.setAttribute('x2', centerX + indexOuterRadius * Math.cos(radians));
line.setAttribute('y2', centerY + indexOuterRadius * Math.sin(radians));
line.setAttribute('stroke', STYLES.textColor);
line.setAttribute('stroke-width', (_i2 % 15 === 0 ? 3 : 2) * scale);
indexSvg.appendChild(line);
// Add numerical label for all 5-second intervals
var textRadius = indexInnerRadius - (_i2 % 15 === 0 ? 25 : 20) * scale;
var textX = centerX + textRadius * Math.cos(radians);
var textY = centerY + textRadius * Math.sin(radians);
var text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', textX);
text.setAttribute('y', textY);
text.setAttribute('fill', STYLES.textColor);
text.setAttribute('font-size', "".concat((_i2 % 15 === 0 ? 16 : 14) * scale, "px"));
text.setAttribute('font-weight', 'bold');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'middle');
text.textContent = _i2.toString();
indexSvg.appendChild(text);
}
clockContainer.appendChild(indexSvg);
var clockCenter = document.createElement('div');
clockCenter.style.position = 'absolute';
clockCenter.style.top = '50%';
clockCenter.style.left = '50%';
clockCenter.style.transform = 'translate(-50%, -50%)';
clockCenter.style.width = "".concat(100 * scale, "px");
clockCenter.style.height = "".concat(100 * scale, "px");
clockCenter.style.borderRadius = '50%';
clockCenter.style.backgroundColor = STYLES.clockBorderColor;
clockCenter.style.display = 'flex';
clockCenter.style.justifyContent = 'center';
clockCenter.style.alignItems = 'center';
clockCenter.style.color = STYLES.backgroundColor;
clockCenter.style.fontWeight = 'bold';
clockCenter.style.fontSize = "".concat(20 * scale, "px"); // Larger font for better readability
clockCenter.style.zIndex = '3'; // Ensure it's on top
clockCenter.textContent = "".concat(Math.round(data.collectedCount / 60 * 100), "%");
clockContainer.appendChild(clockCenter);
container.appendChild(clockContainer);
addDownloadButton(container);
// Add explanation
var explanation = document.createElement('div');
explanation.style.marginTop = isMobile ? '10px' : '20px';
explanation.style.color = STYLES.subtleTextColor;
explanation.style.fontSize = isMobile ? '0.8em' : '0.9em';
explanation.style.padding = isMobile ? '0 5px' : '0';
explanation.innerHTML = "Stopwatch Bingo: collect finish times with every second from 00-59.
Orange segments show seconds you've collected. Segment length indicates frequency.
Click on any segment to see details.";
container.appendChild(explanation);
return container;
}
function addDownloadButton(container) {
var isMobile = window.innerWidth < 768;
var btnContainer = document.createElement('div');
btnContainer.style.marginTop = isMobile ? '10px' : '15px';
btnContainer.id = 'bingo-download-btn-container';
var downloadBtn = document.createElement('button');
downloadBtn.textContent = '💾 Save as Image';
downloadBtn.style.padding = isMobile ? '6px 12px' : '8px 15px';
downloadBtn.style.backgroundColor = STYLES.clockBorderColor;
downloadBtn.style.color = STYLES.backgroundColor;
downloadBtn.style.border = 'none';
downloadBtn.style.borderRadius = '4px';
downloadBtn.style.cursor = 'pointer';
downloadBtn.style.fontWeight = 'bold';
downloadBtn.style.fontSize = isMobile ? '0.9em' : '1em';
// Add hover effect
downloadBtn.addEventListener('mouseover', function () {
this.style.backgroundColor = '#e59200';
});
downloadBtn.addEventListener('mouseout', function () {
this.style.backgroundColor = STYLES.clockBorderColor;
});
// Download handler
downloadBtn.addEventListener('click', function () {
// Hide the download button temporarily for the screenshot
downloadBtn.style.display = 'none';
// Use the entire container instead of just the clock
// eslint-disable-next-line no-undef
html2canvas(container, {
backgroundColor: STYLES.backgroundColor,
scale: 2,
// Higher resolution
logging: false,
allowTaint: true,
useCORS: true
}).then(function (canvas) {
// Show the button again
downloadBtn.style.display = 'block';
var link = document.createElement('a');
var timestamp = new Date().toISOString().split('T')[0];
var pageUrl = window.location.pathname.split('/');
var parkrunnerId = pageUrl[2] || 'parkrunner';
link.download = "stopwatch-bingo-".concat(parkrunnerId, "-").concat(timestamp, ".png");
link.href = canvas.toDataURL('image/png');
link.click();
});
});
btnContainer.appendChild(downloadBtn);
container.appendChild(btnContainer);
}
function showSecondDetails(second, times) {
// Remove any existing details pop-up
var existingPopup = document.getElementById('second-details-popup');
if (existingPopup) {
existingPopup.remove();
}
// Create popup
var popup = document.createElement('div');
popup.id = 'second-details-popup';
popup.style.position = 'fixed';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.width = '400px';
popup.style.maxHeight = '500px';
popup.style.overflowY = 'auto';
popup.style.backgroundColor = STYLES.backgroundColor;
popup.style.borderRadius = '8px';
popup.style.padding = '20px';
popup.style.boxShadow = '0 0 20px rgba(0, 0, 0, 0.5)';
popup.style.zIndex = '1000';
popup.style.color = STYLES.textColor;
// Add close button
var closeBtn = document.createElement('div');
closeBtn.textContent = '✕';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '10px';
closeBtn.style.right = '15px';
closeBtn.style.fontSize = '20px';
closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', function () {
return popup.remove();
});
popup.appendChild(closeBtn);
// Add title
var title = document.createElement('h3');
title.textContent = "".concat(second.toString().padStart(2, '0'), " Seconds - ").concat(times.length, " Times");
title.style.marginBottom = '15px';
title.style.color = STYLES.clockBorderColor;
popup.appendChild(title);
// Create table of occurrences
var table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.style.marginTop = '10px';
// Add table header
var thead = document.createElement('thead');
var headers = [{
title: 'Date',
align: 'left'
}, {
title: 'Event',
align: 'left'
}, {
title: 'Time',
align: 'left'
}];
var headerRow = document.createElement('tr');
headers.forEach(function (header) {
var th = document.createElement('th');
th.textContent = header.title;
th.style.textAlign = header.align;
th.style.padding = '8px';
th.style.borderBottom = '1px solid #555';
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Add table body
var tbody = document.createElement('tbody');
times.forEach(function (time, index) {
var row = document.createElement('tr');
row.style.backgroundColor = index === 0 ? 'rgba(255, 163, 0, 0.2)' : 'transparent';
var cells = [{
content: time.date
}, {
content: time.event
}, {
content: time.time
}].map(function (cell) {
return "".concat(cell.content, " | ");
}).join('');
row.innerHTML = cells;
tbody.appendChild(row);
});
table.appendChild(tbody);
popup.appendChild(table);
document.body.appendChild(popup);
// Add backdrop and close on click
var backdrop = document.createElement('div');
backdrop.style.position = 'fixed';
backdrop.style.top = '0';
backdrop.style.left = '0';
backdrop.style.width = '100%';
backdrop.style.height = '100%';
backdrop.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
backdrop.style.zIndex = '999';
backdrop.addEventListener('click', function () {
popup.remove();
backdrop.remove();
});
document.body.appendChild(backdrop);
}
function insertAfterTitle(element) {
var pageTitle = document.querySelector('h2');
if (pageTitle && pageTitle.parentNode) {
if (pageTitle.nextSibling) {
pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling);
} else {
pageTitle.parentNode.appendChild(element);
}
}
}
function initStopwatchBingo() {
var resultsTable = document.querySelector('#results');
if (!resultsTable) {
console.log('Results table not found');
return;
}
// Extract name from the page title
var pageTitle = document.querySelector('h2');
if (!pageTitle) return;
var table = findResultsTable();
var data = extractFinishTimes(table);
var clockContainer = createBingoClock(data);
insertAfterTitle(clockContainer);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
findResultsTable: findResultsTable,
extractFinishTimes: extractFinishTimes
};
} else {
initStopwatchBingo();
}
})();