// ==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://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://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.24 // ==/UserScript== (function () { 'use strict'; const STYLES = { backgroundColor: '#2b223d', clockBorderColor: '#FFA300', clockFaceColor: '#333', completedColor: '#FFA300', pendingColor: '#53BA9D', textColor: '#e0e0e0', subtleTextColor: '#cccccc', }; function findResultsTable() { const tables = document.querySelectorAll('#results'); return tables[tables.length - 1]; } function extractFinishTimes(table) { const seconds = {}; const timeData = {}; let collectedCount = 0; let totalParkruns = 0; let firstCompleteEvent = null; let dateOfCompletion = null; const rows = table.querySelectorAll('tr'); // First collect all results since they're in reverse order const allResults = []; rows.forEach((row) => { const cells = row.querySelectorAll('td'); if (cells.length < 5) return; const eventName = cells[0].textContent.trim(); const date = cells[1].textContent.trim(); const eventNumber = cells[2].textContent.trim(); const time = cells[4].textContent.trim(); if (!time || !eventNumber || !date || !eventName) return; const event = `${eventName} # ${eventNumber}`; // Parse seconds from time (either MM:SS or HH:MM:SS format) let secondValue; const hourMatch = time.match(/(\d+):(\d+):(\d+)/); if (hourMatch) { secondValue = parseInt(hourMatch[3], 10); } else { const minuteMatch = time.match(/(\d+):(\d+)/); if (!minuteMatch) return; secondValue = parseInt(minuteMatch[2], 10); } allResults.push({ secondValue, date, event, time, }); }); // Process in chronological order (reverse the array) const reversedResults = allResults.reverse(); for (let i = 0; i < reversedResults.length; i++) { const result = reversedResults[i]; const { secondValue, date, event, time } = result; totalParkruns++; // Only store if we haven't seen this second yet if (!(secondValue in seconds)) { seconds[secondValue] = { date, event, time, }; collectedCount++; // Check if we've completed the bingo if (collectedCount === 60 && !dateOfCompletion) { dateOfCompletion = date; firstCompleteEvent = event; timeData[secondValue] = [{ date, event, time }]; break; } } // Store all occurrences for tooltip data if (!timeData[secondValue]) { timeData[secondValue] = []; } timeData[secondValue].push({ date, event, time, }); } return { seconds, timeData, collectedCount, totalParkruns, dateOfCompletion, firstCompleteEvent, }; } function createClockContainer(title) { const container = document.createElement('div'); container.className = 'parkrun-bingo-container'; container.style.width = '100%'; container.style.maxWidth = '800px'; container.style.margin = '20px auto'; container.style.padding = '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'; const heading = document.createElement('h3'); heading.textContent = title; heading.style.marginBottom = '15px'; heading.style.color = STYLES.clockBorderColor; container.appendChild(heading); return container; } function createBingoClock(data) { const container = createClockContainer('Stopwatch Bingo Challenge'); // Add stats const statsContainer = document.createElement('div'); statsContainer.style.marginBottom = '20px'; statsContainer.style.color = STYLES.textColor; let statsText = `
${data.collectedCount} of 60 seconds collected
`; statsText += `
After ${data.totalParkruns} parkruns
`; if (data.dateOfCompletion) { statsText += `
🏆 Bingo completed on ${data.dateOfCompletion} (${data.firstCompleteEvent})
`; } statsContainer.innerHTML = statsText; container.appendChild(statsContainer); // Create clock face const clockContainer = document.createElement('div'); clockContainer.style.position = 'relative'; clockContainer.style.width = '500px'; clockContainer.style.height = '500px'; clockContainer.style.margin = '0 auto'; clockContainer.style.borderRadius = '50%'; clockContainer.style.border = `10px solid ${STYLES.clockBorderColor}`; clockContainer.style.backgroundColor = STYLES.clockFaceColor; clockContainer.style.boxSizing = 'content-box'; // Add the second segments first (so they're at the bottom layer) for (let i = 0; i < 60; i++) { const hasSecond = i in data.seconds; const occurrences = hasSecond && data.timeData[i] ? data.timeData[i].length : 0; // Calculate angles for this segment (0 seconds at top, moving clockwise) const startAngle = (((i - 0.4) / 60) * 360 - 90) * (Math.PI / 180); const endAngle = (((i + 0.4) / 60) * 360 - 90) * (Math.PI / 180); // Create a segment using SVG path const 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 // Calculate path for pie slice - go all the way to center const outerRadius = 220; // Leave space for indices const centerX = 250; const centerY = 250; // Create SVG element for the segment const 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 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // Calculate the points for the path - with innerRadius 0, we go to center // For segments from center, we simplify the path const x2 = centerX + outerRadius * Math.cos(startAngle); const y2 = centerY + outerRadius * Math.sin(startAngle); const x3 = centerX + outerRadius * Math.cos(endAngle); const y3 = centerY + outerRadius * Math.sin(endAngle); // Path goes from center to outer edge, arcs around, then back to center const pathData = [ `M ${centerX},${centerY}`, `L ${x2},${y2}`, `A ${outerRadius},${outerRadius} 0 0,1 ${x3},${y3}`, 'Z', ].join(' '); path.setAttribute('d', pathData); // Vary color intensity based on frequency if (hasSecond) { // Calculate color intensity - darker as frequency increases // Start with base orange color (STYLES.completedColor) and adjust saturation/lightness // Max intensity at around 5+ occurrences const intensity = Math.min(occurrences, 5) / 5; // 0.2 per occurrence up to 1.0 // Either darken the orange or make it more saturated based on frequency const baseColor = STYLES.completedColor; // '#FFA300' const r = parseInt(baseColor.slice(1, 3), 16); const g = parseInt(baseColor.slice(3, 5), 16); const b = parseInt(baseColor.slice(5, 7), 16); // Darken the color as frequency increases (multiply RGB values) const darkenFactor = 1 - intensity * 0.3; // Ranges from 1.0 to 0.7 const newR = Math.floor(r * darkenFactor) .toString(16) .padStart(2, '0'); const newG = Math.floor(g * darkenFactor) .toString(16) .padStart(2, '0'); const newB = Math.floor(b * darkenFactor) .toString(16) .padStart(2, '0'); const frequencyColor = `#${newR}${newG}${newB}`; path.setAttribute('fill', frequencyColor); path.setAttribute('opacity', '1'); // Update title to include frequency information const secondData = data.seconds[i]; path.setAttribute( 'title', `${secondData.time} - ${secondData.date} ${secondData.event} (${occurrences} occurrences)` ); } else { path.setAttribute('fill', STYLES.pendingColor); path.setAttribute('opacity', '0.4'); path.setAttribute('title', `Missing: ${i.toString().padStart(2, '0')} seconds`); } // Add click handler directly to the path for interaction if (hasSecond) { path.style.cursor = 'pointer'; path.style.pointerEvents = 'auto'; // Make path clickable path.addEventListener('click', () => { showSecondDetails(i, data.timeData[i]); }); } svg.appendChild(path); segment.appendChild(svg); clockContainer.appendChild(segment); } // Add stopwatch indices (markers at 5-second intervals) OVER the segments const 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 (let i = 0; i < 60; i += 5) { // Calculate angle for this index const angle = (i / 60) * 360 - 90; // -90 to start at top const radians = angle * (Math.PI / 180); // Calculate position (outer edge of the clock) const indexOuterRadius = 245; // Just inside the border const indexInnerRadius = i % 15 === 0 ? 210 : 225; // Longer marks for 0, 15, 30, 45 const centerX = 250; const centerY = 250; // Create line for index mark const 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', i % 15 === 0 ? 3 : 2); indexSvg.appendChild(line); // Add numerical label for all 5-second intervals const textRadius = i % 15 === 0 ? indexInnerRadius - 25 : indexInnerRadius - 20; const textX = centerX + textRadius * Math.cos(radians); const textY = centerY + textRadius * Math.sin(radians); const 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', i % 15 === 0 ? '16px' : '14px'); text.setAttribute('font-weight', 'bold'); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.textContent = i.toString(); indexSvg.appendChild(text); } clockContainer.appendChild(indexSvg); const 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 = '100px'; clockCenter.style.height = '100px'; 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 = '20px'; // Larger font for better readability clockCenter.style.zIndex = '3'; // Ensure it's on top clockCenter.textContent = `${Math.round((data.collectedCount / 60) * 100)}%`; clockContainer.appendChild(clockCenter); container.appendChild(clockContainer); addDownloadButton(container); // Add explanation const explanation = document.createElement('div'); explanation.style.marginTop = '20px'; explanation.style.color = STYLES.subtleTextColor; explanation.style.fontSize = '0.9em'; explanation.innerHTML = "Stopwatch Bingo: collect finish times with every second from 00-59.
Orange segments show seconds you've collected, green segments are still needed.
Click on any segment to see details."; container.appendChild(explanation); return container; } function addDownloadButton(container) { const btnContainer = document.createElement('div'); btnContainer.style.marginTop = '15px'; btnContainer.id = 'bingo-download-btn-container'; const downloadBtn = document.createElement('button'); downloadBtn.textContent = '💾 Save as Image'; downloadBtn.style.padding = '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'; // 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((canvas) => { // Show the button again downloadBtn.style.display = 'block'; const link = document.createElement('a'); const timestamp = new Date().toISOString().split('T')[0]; const pageUrl = window.location.pathname.split('/'); const parkrunnerId = pageUrl[2] || 'parkrunner'; link.download = `stopwatch-bingo-${parkrunnerId}-${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 const existingPopup = document.getElementById('second-details-popup'); if (existingPopup) { existingPopup.remove(); } // Create popup const 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 const 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', () => popup.remove()); popup.appendChild(closeBtn); // Add title const title = document.createElement('h3'); title.textContent = `${second.toString().padStart(2, '0')} Seconds - ${times.length} Times`; title.style.marginBottom = '15px'; title.style.color = STYLES.clockBorderColor; popup.appendChild(title); // Create table of occurrences const table = document.createElement('table'); table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.marginTop = '10px'; // Add table header const thead = document.createElement('thead'); const headers = [ { title: 'Date', align: 'left' }, { title: 'Event', align: 'left' }, { title: 'Time', align: 'left' }, ]; const headerRow = document.createElement('tr'); headers.forEach((header) => { const 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 const tbody = document.createElement('tbody'); times.forEach((time, index) => { const row = document.createElement('tr'); row.style.backgroundColor = index === 0 ? 'rgba(255, 163, 0, 0.2)' : 'transparent'; const cells = [{ content: time.date }, { content: time.event }, { content: time.time }] .map( (cell) => `${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 const 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', () => { popup.remove(); backdrop.remove(); }); document.body.appendChild(backdrop); } function insertAfterTitle(element) { const pageTitle = document.querySelector('h2'); if (pageTitle && pageTitle.parentNode) { if (pageTitle.nextSibling) { pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling); } else { pageTitle.parentNode.appendChild(element); } } } function initStopwatchBingo() { const resultsTable = document.querySelector('#results'); if (!resultsTable) { console.log('Results table not found'); return; } // Extract name from the page title const pageTitle = document.querySelector('h2'); if (!pageTitle) return; const table = findResultsTable(); const data = extractFinishTimes(table); const clockContainer = createBingoClock(data); insertAfterTitle(clockContainer); } if (typeof module !== 'undefined' && module.exports) { module.exports = { findResultsTable, extractFinishTimes, }; } else { initStopwatchBingo(); } })();