// ==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://www.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 // @require https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js // @version 0.1.5 // ==/UserScript== // DO NOT EDIT - generated from src/ by scripts/build-scripts.js function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } function _regenerator() { /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ var e, t, r = "function" == typeof Symbol ? Symbol : {}, n = r.iterator || "@@iterator", o = r.toStringTag || "@@toStringTag"; function i(r, n, o, i) { var c = n && n.prototype instanceof Generator ? n : Generator, u = Object.create(c.prototype); return _regeneratorDefine2(u, "_invoke", function (r, n, o) { var i, c, u, f = 0, p = o || [], y = !1, G = { p: 0, n: 0, v: e, a: d, f: d.bind(e, 4), d: function d(t, r) { return i = t, c = 0, u = e, G.n = r, a; } }; function d(r, n) { for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { var o, i = p[t], d = G.p, l = i[2]; r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); } if (o || r > 1) return a; throw y = !0, n; } return function (o, p, l) { if (f > 1) throw TypeError("Generator is already running"); for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); try { if (f = 2, i) { if (c || (o = "next"), t = i[o]) { if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); if (!t.done) return t; u = t.value, c < 2 && (c = 0); } else 1 === c && (t = i.return) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); i = e; } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; } catch (t) { i = e, c = 1, u = t; } finally { f = 1; } } return { value: t, done: y }; }; }(r, o, i), !0), u; } var a = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} t = Object.getPrototypeOf; var c = [][n] ? t(t([][n]())) : (_regeneratorDefine2(t = {}, n, function () { return this; }), t), u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); function f(e) { return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, _regeneratorDefine2(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, _regeneratorDefine2(u, "constructor", GeneratorFunctionPrototype), _regeneratorDefine2(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", _regeneratorDefine2(GeneratorFunctionPrototype, o, "GeneratorFunction"), _regeneratorDefine2(u), _regeneratorDefine2(u, o, "Generator"), _regeneratorDefine2(u, n, function () { return this; }), _regeneratorDefine2(u, "toString", function () { return "[object Generator]"; }), (_regenerator = function _regenerator() { return { w: i, m: f }; })(); } function _regeneratorDefine2(e, r, n, t) { var i = Object.defineProperty; try { i({}, "", {}); } catch (e) { i = 0; } _regeneratorDefine2 = function _regeneratorDefine(e, r, n, t) { function o(r, n) { _regeneratorDefine2(e, r, function (e) { return this._invoke(r, n, e); }); } r ? i ? i(e, r, { value: n, enumerable: !t, configurable: !t, writable: !t }) : e[r] = n : (o("next", 0), o("throw", 1), o("return", 2)); }, _regeneratorDefine2(e, r, n, t); } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); } function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); } function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } var BASELINE_EVENTS = 12; var GAP_THRESHOLD_DAYS = 7; function calculateBaseline(data) { if (data.dates.length === 0) { return { avgFinishers: 0, avgVolunteers: 0, totalEvents: 0, minFinishers: 0, maxFinishers: 0, minVolunteers: 0, maxVolunteers: 0 }; } var avgFinishers = Math.round(data.finishers.reduce(function (a, b) { return a + b; }, 0) / data.dates.length); var avgVolunteers = Math.round(data.volunteers.reduce(function (a, b) { return a + b; }, 0) / data.dates.length); return { avgFinishers: avgFinishers, avgVolunteers: avgVolunteers, totalEvents: data.dates.length, minFinishers: Math.min.apply(Math, _toConsumableArray(data.finishers)), maxFinishers: Math.max.apply(Math, _toConsumableArray(data.finishers)), minVolunteers: Math.min.apply(Math, _toConsumableArray(data.volunteers)), maxVolunteers: Math.max.apply(Math, _toConsumableArray(data.volunteers)) }; } function calculateDistance(lat1, lon1, lat2, lon2) { var R = 6371; var dLat = (lat2 - lat1) * Math.PI / 180; var dLon = (lon2 - lon1) * Math.PI / 180; var 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); var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } function calculateBearing(lat1, lon1, lat2, lon2) { var dLon = (lon2 - lon1) * Math.PI / 180; var lat1r = lat1 * Math.PI / 180; var lat2r = lat2 * Math.PI / 180; var y = Math.sin(dLon) * Math.cos(lat2r); var x = Math.cos(lat1r) * Math.sin(lat2r) - Math.sin(lat1r) * Math.cos(lat2r) * Math.cos(dLon); var bearing = Math.atan2(y, x) * 180 / Math.PI; if (bearing < 0) bearing += 360; return bearing; } function detectAllEventGaps(historyData, referenceDate) { var dates = historyData.rawDates.map(function (d) { return parseDateString(d); }); if (dates.length < 1) { return []; } var gaps = []; for (var i = 1; i < dates.length; i++) { var prevDate = dates[i - 1]; var currDate = dates[i]; var daysDiff = (currDate - prevDate) / (1000 * 60 * 60 * 24); if (daysDiff > GAP_THRESHOLD_DAYS) { gaps.push({ gapStartDate: prevDate, gapEndDate: currDate, daysDiff: daysDiff, eventsBefore: i, eventsAfter: dates.length - i }); } } if (referenceDate && dates.length >= 1) { var lastDate = dates[dates.length - 1]; var refStr = toLocalDateString(referenceDate); var refDate = parseDateString(refStr); var _daysDiff = (refDate - lastDate) / (1000 * 60 * 60 * 24); if (_daysDiff > GAP_THRESHOLD_DAYS) { gaps.push({ gapStartDate: lastDate, gapEndDate: refDate, gapEndDateStr: refStr, daysDiff: _daysDiff, eventsBefore: dates.length, eventsAfter: 0 }); } } return gaps; } function detectEventGap(historyData, referenceDate) { var dates = historyData.rawDates.map(function (d) { return parseDateString(d); }); if (dates.length < 1) { return null; } var lastDate = dates[dates.length - 1]; // Prefer ongoing cancellation (last event to reference date) when it exists if (referenceDate) { var refStr = toLocalDateString(referenceDate); var refDate = parseDateString(refStr); var daysDiff = (refDate - lastDate) / (1000 * 60 * 60 * 24); if (daysDiff > GAP_THRESHOLD_DAYS) { return { gapStartDate: lastDate, gapEndDate: refDate, gapEndDateStr: refStr, daysDiff: daysDiff, eventsBefore: dates.length, eventsAfter: 0 }; } } var gaps = []; for (var i = 1; i < dates.length; i++) { var prevDate = dates[i - 1]; var currDate = dates[i]; var _daysDiff2 = (currDate - prevDate) / (1000 * 60 * 60 * 24); if (_daysDiff2 > GAP_THRESHOLD_DAYS) { gaps.push({ gapStartDate: prevDate, gapEndDate: currDate, daysDiff: _daysDiff2, eventsBefore: i, eventsAfter: dates.length - i }); } } if (gaps.length > 0) { return gaps[gaps.length - 1]; } return null; } function filterEventsByDateRange(historyData, startDate, endDate) { var filtered = { dates: [], finishers: [], volunteers: [] }; historyData.rawDates.forEach(function (dateStr, index) { var 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 getBaselineEventsBefore(historyData, targetDate) { var n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : BASELINE_EVENTS; var targetStr = targetDate.toISOString().split('T')[0]; var parsedTarget = parseDateString(targetStr); var indices = []; for (var i = historyData.rawDates.length - 1; i >= 0; i--) { var eventDate = parseDateString(historyData.rawDates[i]); if (eventDate < parsedTarget) { indices.push(i); if (indices.length >= n) break; } } indices.reverse(); var filtered = { dates: indices.map(function (i) { return historyData.dates[i]; }), finishers: indices.map(function (i) { return historyData.finishers[i]; }), volunteers: indices.map(function (i) { return historyData.volunteers[i]; }) }; var baseline = calculateBaseline(filtered); var window = indices.length > 0 ? { start: parseDateString(historyData.rawDates[indices[0]]), end: parseDateString(historyData.rawDates[indices[indices.length - 1]]) } : { start: new Date(parsedTarget), end: new Date(parsedTarget) }; return { filtered: filtered, window: window, baseline: baseline }; } function dayOfWeekForDateString(dateStr) { var _dateStr$split$map = dateStr.split('-').map(Number), _dateStr$split$map2 = _slicedToArray(_dateStr$split$map, 3), y = _dateStr$split$map2[0], m = _dateStr$split$map2[1], d = _dateStr$split$map2[2]; var q = d; var month = m < 3 ? m + 12 : m; var year = m < 3 ? y - 1 : y; var K = year % 100; var J = Math.floor(year / 100); var h = (q + Math.floor(13 * (month + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7; return (h + 6) % 7; // 0=Sun, 6=Sat } function addDaysToDateString(dateStr, days) { var d = parseDateString(dateStr); var msPerDay = 24 * 60 * 60 * 1000; var result = new Date(d.getTime() + days * msPerDay); return result.toISOString().split('T')[0]; } function getCancellationSaturdays(gapStartDate, gapEndDate) { var saturdays = []; var startStr = gapStartDate.toISOString().split('T')[0]; var startDayOfWeek = dayOfWeekForDateString(startStr); var daysUntilSaturday = (6 - startDayOfWeek) % 7; if (daysUntilSaturday === 0) { daysUntilSaturday = 7; } var currentStr = addDaysToDateString(startStr, daysUntilSaturday); var current = parseDateString(currentStr); while (current < gapEndDate) { saturdays.push(new Date(current)); currentStr = addDaysToDateString(currentStr, 7); current = parseDateString(currentStr); } return saturdays; } function getMostRecentCancellationDate(gapInfo) { if (!gapInfo) return null; var refDateStr = gapInfo.gapEndDateStr; if (refDateStr && dayOfWeekForDateString(refDateStr) === 6) { var _refDateStr$split$map = refDateStr.split('-').map(Number), _refDateStr$split$map2 = _slicedToArray(_refDateStr$split$map, 3), y = _refDateStr$split$map2[0], m = _refDateStr$split$map2[1], d = _refDateStr$split$map2[2]; return new Date(y, m - 1, d); } var sats = getCancellationSaturdays(gapInfo.gapStartDate, gapInfo.gapEndDate); return sats.length > 0 ? sats[sats.length - 1] : null; } function parseDateString(dateStr) { return new Date("".concat(dateStr, "T00:00:00Z")); } function toLocalDateString(date) { var y = date.getFullYear(); var m = String(date.getMonth() + 1).padStart(2, '0'); var d = String(date.getDate()).padStart(2, '0'); return "".concat(y, "-").concat(m, "-").concat(d); } function getNotHeldLabel(historyData, cancellationDate) { var _historyData$rawDates; if (!(historyData !== null && historyData !== void 0 && (_historyData$rawDates = historyData.rawDates) !== null && _historyData$rawDates !== void 0 && _historyData$rawDates.length)) return null; var launchDate = parseDateString(historyData.rawDates[0]); var cancelStr = cancellationDate.toISOString().split('T')[0]; var cancelDate = parseDateString(cancelStr); if (launchDate <= cancelDate) return null; return "Launched ".concat(historyData.dates[0]); } function isFinishersMaxUpToEvent(historyData, targetEventNumber, targetFinishers) { if (!historyData || !historyData.eventNumbers || historyData.eventNumbers.length === 0) { return false; } var targetIdx = historyData.eventNumbers.indexOf(String(targetEventNumber)); if (targetIdx === -1) { return false; } // Check if targetFinishers is the max from event 1 (index 0) to targetEventNumber (targetIdx, inclusive) var eventFinishersUpToTarget = historyData.finishers.slice(0, targetIdx + 1); var maxUpToTarget = Math.max.apply(Math, _toConsumableArray(eventFinishersUpToTarget)); return targetFinishers === maxUpToTarget; } var WAF_TITLE = 'JavaScript is disabled'; function isInvalidHistoryData(data) { return !data || data.title === WAF_TITLE; } (function () { 'use strict'; var 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 }; var CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours var state = { currentEvent: null, allParkruns: null, gapInfo: null, nearbyParkruns: [], fetchController: null, analysisComplete: false, impactData: null, currentCancellationIndex: -1, cancellationDates: [], sortColumn: 'distance', sortDirection: 'asc' }; function insertAfterFirst(selector, element) { var pageTitle = document.querySelector(selector); if (pageTitle && pageTitle.parentNode) { if (pageTitle.nextSibling) { pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling); } else { pageTitle.parentNode.appendChild(element); } } } function fetchAllParkruns() { return _fetchAllParkruns.apply(this, arguments); } function _fetchAllParkruns() { _fetchAllParkruns = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() { var CACHE_KEY, _data$events, cached, _JSON$parse, _data, timestamp, age, response, data, features, _t3, _t4; return _regenerator().w(function (_context3) { while (1) switch (_context3.p = _context3.n) { case 0: CACHE_KEY = 'parkrun_events_cache'; _context3.p = 1; cached = localStorage.getItem(CACHE_KEY); if (!cached) { _context3.n = 5; break; } _context3.p = 2; _JSON$parse = JSON.parse(cached), _data = _JSON$parse.data, timestamp = _JSON$parse.timestamp; age = Date.now() - timestamp; if (!(age < CACHE_DURATION_MS)) { _context3.n = 3; break; } console.log("Using cached parkrun events (".concat(Math.round(age / 1000 / 60), " minutes old)")); return _context3.a(2, _data); case 3: _context3.n = 5; break; case 4: _context3.p = 4; _t3 = _context3.v; console.log('Cache parse error, fetching fresh data', _t3); case 5: console.log('Fetching parkrun events from https://images.parkrun.com/events.json'); _context3.n = 6; return fetch('https://images.parkrun.com/events.json'); case 6: response = _context3.v; if (response.ok) { _context3.n = 7; break; } console.error('Fetch failed with status:', response.status); return _context3.a(2, []); case 7: _context3.n = 8; return response.json(); case 8: data = _context3.v; features = ((_data$events = data.events) === null || _data$events === void 0 ? void 0 : _data$events.features) || data.features || []; if (!(!features || features.length === 0)) { _context3.n = 9; break; } console.error('No features found in response data'); return _context3.a(2, []); case 9: 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 _context3.a(2, features); case 10: _context3.p = 10; _t4 = _context3.v; console.error('Failed to fetch parkruns:', _t4); return _context3.a(2, []); } }, _callee3, null, [[2, 4], [1, 10]]); })); return _fetchAllParkruns.apply(this, arguments); } function getCurrentEventInfo() { var pathParts = window.location.pathname.split('/'); var eventName = pathParts[1]; var domain = window.location.hostname; return { eventName: eventName, domain: domain, url: window.location.origin }; } function findNearbyParkruns(currentEvent, allParkruns) { var maxDistanceKm = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 50; var current = allParkruns.find(function (p) { return p.properties.eventname === currentEvent.eventName; }); if (!current) return []; var _current$geometry$coo = _slicedToArray(current.geometry.coordinates, 2), currentLon = _current$geometry$coo[0], currentLat = _current$geometry$coo[1]; var currentCountry = current.properties.countrycode; var currentSeries = current.properties.seriesid; return allParkruns.filter(function (parkrun) { if (parkrun.properties.eventname === currentEvent.eventName) return false; if (parkrun.properties.countrycode !== currentCountry) return false; if (parkrun.properties.seriesid !== currentSeries) return false; var _parkrun$geometry$coo = _slicedToArray(parkrun.geometry.coordinates, 2), lon = _parkrun$geometry$coo[0], lat = _parkrun$geometry$coo[1]; var latDiff = Math.abs(lat - currentLat); var lonDiff = Math.abs(lon - currentLon); if (latDiff > 0.5 || lonDiff > 0.5) return false; var distance = calculateDistance(currentLat, currentLon, lat, lon); return distance <= maxDistanceKm; }).map(function (parkrun) { var _parkrun$geometry$coo2 = _slicedToArray(parkrun.geometry.coordinates, 2), lon = _parkrun$geometry$coo2[0], lat = _parkrun$geometry$coo2[1]; var distance = calculateDistance(currentLat, currentLon, lat, lon); return _objectSpread(_objectSpread({}, parkrun), {}, { distance: distance }); }).sort(function (a, b) { return a.distance - b.distance; }); } function extractEventHistoryData() { var _document$querySelect, _document$querySelect2; var title = (_document$querySelect = (_document$querySelect2 = document.querySelector('h1')) === null || _document$querySelect2 === void 0 ? void 0 : _document$querySelect2.textContent.trim()) !== null && _document$querySelect !== void 0 ? _document$querySelect : 'Event History'; var eventNumbers = []; var dates = []; var rawDates = []; var finishers = []; var volunteers = []; var rows = document.querySelectorAll('tr.Results-table-row'); Array.from(rows).reverse().forEach(function (row) { var eventNumber = row.getAttribute('data-parkrun'); if (eventNumber) { eventNumbers.push(eventNumber); } var date = row.getAttribute('data-date'); if (date) { rawDates.push(date); var dateObj = new Date(date); var formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); dates.push(formattedDate); } var finishersCount = row.getAttribute('data-finishers'); if (finishersCount) { finishers.push(parseInt(finishersCount, 10)); } var volunteersCount = row.getAttribute('data-volunteers'); if (volunteersCount) { volunteers.push(parseInt(volunteersCount, 10)); } }); return { title: title, eventNumbers: eventNumbers, dates: dates, rawDates: rawDates, finishers: finishers, volunteers: volunteers }; } function findEventOnDate(historyData, targetDate) { var targetStr = targetDate.toISOString().split('T')[0]; for (var 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; } function fetchEventHistory(_x, _x2) { return _fetchEventHistory.apply(this, arguments); } function _fetchEventHistory() { _fetchEventHistory = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee4(eventName, domain) { var CACHE_KEY, _doc$querySelector$te, _doc$querySelector, cached, _JSON$parse2, data, timestamp, age, url, response, html, parser, doc, title, eventNumbers, dates, rawDates, finishers, volunteers, rows, historyData, _t5, _t6; return _regenerator().w(function (_context4) { while (1) switch (_context4.p = _context4.n) { case 0: CACHE_KEY = "parkrun_history_".concat(eventName); _context4.p = 1; cached = localStorage.getItem(CACHE_KEY); if (!cached) { _context4.n = 6; break; } _context4.p = 2; _JSON$parse2 = JSON.parse(cached), data = _JSON$parse2.data, timestamp = _JSON$parse2.timestamp; if (!isInvalidHistoryData(data)) { _context4.n = 3; break; } localStorage.removeItem(CACHE_KEY); console.log("Discarding invalid cached history for ".concat(eventName, ", re-fetching")); _context4.n = 4; break; case 3: age = Date.now() - timestamp; if (!(age < CACHE_DURATION_MS)) { _context4.n = 4; break; } console.log("Using cached history for ".concat(eventName, " (").concat(Math.round(age / 1000 / 60), " minutes old)")); return _context4.a(2, data); case 4: _context4.n = 6; break; case 5: _context4.p = 5; _t5 = _context4.v; console.log("Cache parse error for ".concat(eventName, ", fetching fresh data"), _t5); case 6: // Fetch from network url = "".concat(domain, "/").concat(eventName, "/results/eventhistory/"); _context4.n = 7; return fetch(url); case 7: response = _context4.v; if (response.ok) { _context4.n = 8; break; } throw new Error("HTTP ".concat(response.status)); case 8: _context4.n = 9; return response.text(); case 9: html = _context4.v; parser = new DOMParser(); doc = parser.parseFromString(html, 'text/html'); title = (_doc$querySelector$te = (_doc$querySelector = doc.querySelector('h1')) === null || _doc$querySelector === void 0 ? void 0 : _doc$querySelector.textContent.trim()) !== null && _doc$querySelector$te !== void 0 ? _doc$querySelector$te : eventName; eventNumbers = []; dates = []; rawDates = []; finishers = []; volunteers = []; rows = doc.querySelectorAll('tr.Results-table-row'); Array.from(rows).reverse().forEach(function (row) { var eventNumber = row.getAttribute('data-parkrun'); if (eventNumber) { eventNumbers.push(eventNumber); } var date = row.getAttribute('data-date'); if (date) { rawDates.push(date); var dateObj = new Date(date); var formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); dates.push(formattedDate); } var finishersCount = row.getAttribute('data-finishers'); if (finishersCount) { finishers.push(parseInt(finishersCount, 10)); } var volunteersCount = row.getAttribute('data-volunteers'); if (volunteersCount) { volunteers.push(parseInt(volunteersCount, 10)); } }); historyData = { eventName: eventName, title: title, eventNumbers: eventNumbers, dates: dates, rawDates: rawDates, finishers: finishers, volunteers: volunteers }; if (!isInvalidHistoryData(historyData)) { _context4.n = 10; break; } console.warn("Event history for ".concat(eventName, " looks invalid (e.g. WAF block), not caching. Retry later.")); return _context4.a(2, null); case 10: try { localStorage.setItem(CACHE_KEY, JSON.stringify({ data: historyData, timestamp: Date.now() })); } catch (cacheError) { console.warn("Failed to cache history for ".concat(eventName, ":"), cacheError); } return _context4.a(2, historyData); case 11: _context4.p = 11; _t6 = _context4.v; console.error("Failed to fetch event history for ".concat(eventName, ":"), _t6); return _context4.a(2, null); } }, _callee4, null, [[2, 5], [1, 11]]); })); return _fetchEventHistory.apply(this, arguments); } function createProgressUI() { var 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 ".concat(STYLES.gridColor); var 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); var 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'; var 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); var progressText = document.createElement('div'); progressText.style.fontSize = '13px'; progressText.style.color = STYLES.subtleTextColor; progressText.style.marginBottom = '12px'; progressSection.appendChild(progressText); var statusText = document.createElement('div'); statusText.style.fontSize = '12px'; statusText.style.color = STYLES.lineColor; statusText.style.fontWeight = 'bold'; statusText.style.marginBottom = '10px'; progressSection.appendChild(statusText); var 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: progressSection, updateProgress: function updateProgress(current, total) { var percent = Math.round(current / total * 100); progressFill.style.width = percent + '%'; progressText.textContent = "".concat(current, "/").concat(total, " parkruns analyzed"); }, updateStatus: function updateStatus(message) { statusText.textContent = message; }, stop: stopButton, hide: function hide() { progressSection.style.display = 'none'; } }; } function renderCancellationSummary(eventShortName) { var 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 ".concat(STYLES.gridColor); var 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); var eventNameDiv = document.createElement('div'); eventNameDiv.style.fontSize = '16px'; eventNameDiv.style.color = STYLES.textColor; eventNameDiv.style.marginBottom = '4px'; eventNameDiv.innerHTML = "").concat(eventShortName, ""); section.appendChild(eventNameDiv); if (state.gapInfo) { var mostRecent = getMostRecentCancellationDate(state.gapInfo); if (mostRecent) { var cancellationDateLine = document.createElement('div'); cancellationDateLine.style.fontSize = '14px'; cancellationDateLine.style.color = STYLES.subtleTextColor; cancellationDateLine.style.marginBottom = '15px'; cancellationDateLine.textContent = "Most recent cancellation date: ".concat(mostRecent.toLocaleDateString('en-AU', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })); section.appendChild(cancellationDateLine); } } var details = document.createElement('div'); details.style.fontSize = '14px'; details.style.lineHeight = '1.8'; details.style.color = STYLES.subtleTextColor; details.style.marginBottom = '18px'; details.innerHTML = "\uD83D\uDCCD Analyzing impact on nearby parkruns within 50km"; section.appendChild(details); var 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', function () { startButton.style.backgroundColor = '#0ea5e9'; // brighter cyan startButton.style.transform = 'translateY(-1px)'; }); startButton.addEventListener('mouseleave', function () { startButton.style.backgroundColor = STYLES.lineColor; startButton.style.transform = 'translateY(0)'; }); section.appendChild(startButton); return { section: section, startButton: startButton }; } function renderCancellationAnalysis() { return _renderCancellationAnalysis.apply(this, arguments); } function _renderCancellationAnalysis() { _renderCancellationAnalysis = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee5() { var _currentParkrun$prope2; var existing, historyData, gapInfo, eventInfo, container, msg, _msg, currentParkrun, eventShortName, _renderCancellationSu, summarySection, startButton; return _regenerator().w(function (_context5) { while (1) switch (_context5.n) { case 0: existing = document.querySelector('.parkrun-cancellation-impact'); if (existing) { existing.remove(); } historyData = extractEventHistoryData(); if (!(historyData.eventNumbers.length === 0)) { _context5.n = 1; break; } console.log('No event history data found'); return _context5.a(2); case 1: gapInfo = detectEventGap(historyData, new Date()); if (gapInfo) { _context5.n = 2; break; } console.log('No cancellation gap detected'); return _context5.a(2); case 2: state.currentEvent = _objectSpread(_objectSpread({}, historyData), {}, { eventName: getCurrentEventInfo().eventName }); state.gapInfo = gapInfo; eventInfo = getCurrentEventInfo(); 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)) { _context5.n = 3; break; } 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 _context5.a(2); case 3: state.nearbyParkruns = findNearbyParkruns(eventInfo, state.allParkruns); if (!(state.nearbyParkruns.length === 0)) { _context5.n = 4; break; } _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 _context5.a(2); case 4: // Get EventShortName for the current event currentParkrun = state.allParkruns.find(function (p) { return p.properties.eventname === eventInfo.eventName; }); eventShortName = (currentParkrun === null || currentParkrun === void 0 || (_currentParkrun$prope2 = currentParkrun.properties) === null || _currentParkrun$prope2 === void 0 ? void 0 : _currentParkrun$prope2.EventShortName) || null; // Cancellation summary with start button _renderCancellationSu = renderCancellationSummary(eventShortName), summarySection = _renderCancellationSu.section, startButton = _renderCancellationSu.startButton; container.appendChild(summarySection); insertAfterFirst('h1', container); // Setup analysis trigger startButton.addEventListener('click', function () { startButton.disabled = true; startButton.textContent = 'Starting...'; startButton.style.opacity = '0.6'; startButton.style.cursor = 'not-allowed'; // Create and show progress UI var progressUI = createProgressUI(); summarySection.insertAdjacentElement('afterend', progressUI.progressSection); // Background fetch state.fetchController = new AbortController(); startBackgroundAnalysis(progressUI, container, summarySection); }); case 5: return _context5.a(2); } }, _callee5); })); return _renderCancellationAnalysis.apply(this, arguments); } function startBackgroundAnalysis(_x3, _x4, _x5) { return _startBackgroundAnalysis.apply(this, arguments); } function _startBackgroundAnalysis() { _startBackgroundAnalysis = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee6(progressUI, container, summarySection) { var eventInfo, nearbyParkruns, allGaps, cancellationSaturdays, nearbyHistories, fetchFailures, i, parkrun, eventName, shortName, distance, historyData, histByEvent, failByEvent, resultsByDate, validCancellationDates, finalCancellationDates, noDataMsg, startBtn, navSection, _t7; return _regenerator().w(function (_context6) { while (1) switch (_context6.p = _context6.n) { case 0: eventInfo = getCurrentEventInfo(); nearbyParkruns = state.nearbyParkruns; // Find all cancellation Saturdays from all gaps allGaps = detectAllEventGaps(state.currentEvent, new Date()); cancellationSaturdays = []; allGaps.forEach(function (gap) { var saturdays = getCancellationSaturdays(gap.gapStartDate, gap.gapEndDate); cancellationSaturdays.push.apply(cancellationSaturdays, _toConsumableArray(saturdays)); if (gap.gapEndDateStr && dayOfWeekForDateString(gap.gapEndDateStr) === 6) { cancellationSaturdays.push(parseDateString(gap.gapEndDateStr)); } }); // Sort by date descending (newest first) cancellationSaturdays.sort(function (a, b) { return b - a; }); state.cancellationDates = cancellationSaturdays; console.log('All Cancellation Saturdays:', cancellationSaturdays); // Fetch all nearby parkrun histories once nearbyHistories = []; fetchFailures = []; i = 0; case 1: if (!(i < nearbyParkruns.length)) { _context6.n = 8; break; } if (!state.fetchController.signal.aborted) { _context6.n = 2; break; } console.log('Analysis stopped by user'); return _context6.a(3, 8); case 2: parkrun = nearbyParkruns[i]; eventName = parkrun.properties.eventname; shortName = parkrun.properties.EventShortName || eventName; distance = parkrun.distance.toFixed(1); progressUI.updateStatus("Fetching: ".concat(shortName, " (").concat(distance, "km)")); progressUI.updateProgress(i, nearbyParkruns.length); _context6.p = 3; _context6.n = 4; return fetchEventHistory(eventName, eventInfo.url); case 4: historyData = _context6.v; if (historyData) { nearbyHistories.push({ parkrun: parkrun, historyData: historyData, shortName: shortName, distance: distance }); } else { fetchFailures.push({ parkrun: parkrun, shortName: shortName, distance: distance }); } _context6.n = 6; break; case 5: _context6.p = 5; _t7 = _context6.v; console.error("Failed to fetch ".concat(eventName, ":"), _t7); fetchFailures.push({ parkrun: parkrun, shortName: shortName, distance: distance }); case 6: _context6.n = 7; return new Promise(function (resolve) { return setTimeout(resolve, 100); }); case 7: i++; _context6.n = 1; break; case 8: histByEvent = new Map(); nearbyHistories.forEach(function (h) { return histByEvent.set(h.parkrun.properties.eventname, h); }); failByEvent = new Map(); fetchFailures.forEach(function (f) { return failByEvent.set(f.parkrun.properties.eventname, f); }); // Compute results for all cancellation dates (one result per nearby parkrun, order preserved) resultsByDate = {}; validCancellationDates = []; cancellationSaturdays.forEach(function (targetDate) { var dateKey = targetDate.toISOString().split('T')[0]; var results = []; nearbyParkruns.forEach(function (parkrun) { var eventName = parkrun.properties.eventname; var hist = histByEvent.get(eventName); var fail = failByEvent.get(eventName); if (hist) { var _historyData = hist.historyData, _shortName = hist.shortName, _distance = hist.distance; var base = getBaselineEventsBefore(_historyData, targetDate); var eventOnDate = findEventOnDate(_historyData, targetDate); results.push({ eventName: parkrun.properties.eventname, title: _historyData.title, displayName: _shortName, distance: _distance, baseline: base.baseline, eventOnDate: eventOnDate, historyData: _historyData, seasonalTrend: base, change: eventOnDate ? { finishersChange: eventOnDate.finishers - base.baseline.avgFinishers, volunteersChange: eventOnDate.volunteers - base.baseline.avgVolunteers, finishersPct: base.baseline.avgFinishers > 0 ? (eventOnDate.finishers - base.baseline.avgFinishers) / base.baseline.avgFinishers * 100 : 0, volunteersPct: base.baseline.avgVolunteers > 0 ? (eventOnDate.volunteers - base.baseline.avgVolunteers) / base.baseline.avgVolunteers * 100 : 0 } : null }); } else if (fail) { results.push({ eventName: eventName, title: eventName, displayName: fail.shortName, distance: fail.distance, baseline: { avgFinishers: 0, avgVolunteers: 0 }, eventOnDate: null, historyData: null, seasonalTrend: null, change: null, fetchFailed: true }); } }); // Check if this was a global cancellation (no parkruns ran) var eventsHeld = results.filter(function (r) { return r.eventOnDate; }).length; if (eventsHeld >= 1) { resultsByDate[dateKey] = results; validCancellationDates.push(targetDate); } else { console.log("Skipping ".concat(dateKey, ": 0/").concat(results.length, " parkruns ran (global cancellation)")); } }); // Use filtered valid dates finalCancellationDates = validCancellationDates.length > 0 ? validCancellationDates : cancellationSaturdays; progressUI.updateProgress(nearbyParkruns.length, nearbyParkruns.length); if (!(validCancellationDates.length === 0)) { _context6.n = 9; break; } progressUI.updateStatus('No valid cancellation dates found - all detected dates had global cancellations'); progressUI.stop.textContent = 'Close'; progressUI.stop.style.backgroundColor = STYLES.alertColor; 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 = "\n
All detected cancellation dates appear to be part of global cancellation periods (e.g., COVID-19).
\n\n No nearby parkruns held events on these dates, indicating system-wide cancellations rather than single-event cancellations.\n
\n "); container.appendChild(noDataMsg); progressUI.stop.addEventListener('click', function () { progressUI.hide(); }); return _context6.a(2); case 9: progressUI.updateStatus("Analysis complete! Found ".concat(validCancellationDates.length, " valid cancellation date(s)")); 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 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(function () { progressUI.hide(); }, 500); // Close progress UI on click (if user wants to close it manually before auto-hide) progressUI.stop.addEventListener('click', function () { progressUI.hide(); }); case 10: return _context6.a(2); } }, _callee6, null, [[3, 5]]); })); return _startBackgroundAnalysis.apply(this, arguments); } function createNavigationControls(container, resultsByDate, cancellationDates, currentDateIndex) { var 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 ".concat(STYLES.gridColor); var navInfo = document.createElement('div'); navInfo.style.color = STYLES.textColor; navInfo.style.fontSize = '14px'; navInfo.style.marginBottom = '12px'; navInfo.innerHTML = "\n ".concat(cancellationDates.length, " Cancellation Date").concat(cancellationDates.length !== 1 ? 's' : '', " Available\n