// ==UserScript== // @name NS Delay Time Converter // @namespace https://github.com/0xMH // @version 1.0.0 // @description Shows actual departure/arrival times on NS.nl by calculating scheduled time + delay // @author 0xMH // @match https://www.ns.nl/* // @grant none // @run-at document-idle // @license MIT // @homepageURL https://github.com/0xMH/ns-delay-converter // @supportURL https://github.com/0xMH/ns-delay-converter/issues // ==/UserScript== (function() { 'use strict'; function timeToMinutes(timeStr) { const match = timeStr.match(/(\d{1,2}):(\d{2})/); if (!match) return null; return parseInt(match[1], 10) * 60 + parseInt(match[2], 10); } function minutesToTime(totalMinutes) { totalMinutes = ((totalMinutes % 1440) + 1440) % 1440; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } function calculateActualTime(scheduledTime, delayMinutes) { const scheduledMinutes = timeToMinutes(scheduledTime); if (scheduledMinutes === null) return null; return minutesToTime(scheduledMinutes + delayMinutes); } function parseDelayText(text) { const match = text.match(/([+-])(\d+)/); if (!match) return null; const sign = match[1]; let delayStr = match[2]; // Handle duplicated delay text (NS quirk) if (delayStr.length % 2 === 0 && delayStr.length >= 2) { const half = delayStr.length / 2; const firstHalf = delayStr.substring(0, half); const secondHalf = delayStr.substring(half); if (firstHalf === secondHalf) { delayStr = firstHalf; } } const delay = parseInt(delayStr, 10); return { minutes: sign === '-' ? -delay : delay, sign: sign, value: parseInt(delayStr, 10) }; } function findTimeForDelay(delayEl) { const prevSibling = delayEl.previousElementSibling; if (prevSibling) { if (prevSibling.tagName.toLowerCase() === 'rio-jp-time') { return prevSibling.textContent.trim(); } const text = prevSibling.textContent; const timeMatch = text.match(/(\d{1,2}:\d{2})/); if (timeMatch) { return timeMatch[1]; } } const parent = delayEl.parentElement; if (parent) { const timeEl = parent.querySelector('rio-jp-time'); if (timeEl) { return timeEl.textContent.trim(); } } const grandparent = delayEl.parentElement?.parentElement; if (grandparent) { const timeEl = grandparent.querySelector('rio-jp-time'); if (timeEl) { return timeEl.textContent.trim(); } } return null; } function isInStopsList(delayEl) { return delayEl.closest('rio-jp-leg') || delayEl.closest('rio-jp-stop') || delayEl.closest('[class*="rio-jp-leg"]') || delayEl.closest('.rio-jp-trip-container rio-jp-delay-container') || delayEl.closest('[class*="nes-flex-col"]'); } function processDelays() { const delayElements = document.querySelectorAll('rio-jp-delay:not([data-ns-converted])'); delayElements.forEach(delayEl => { const delayText = delayEl.textContent.trim(); if (!delayText || delayText === '') return; const delayInfo = parseDelayText(delayText); if (!delayInfo || delayInfo.minutes === 0) return; const timeText = findTimeForDelay(delayEl); if (!timeText) return; const actualTime = calculateActualTime(timeText, delayInfo.minutes); if (!actualTime) return; const delaySpan = delayEl.querySelector('.rio-jp-delay'); if (delaySpan) { if (isInStopsList(delayEl)) { delaySpan.innerHTML = `${delayInfo.sign}${delayInfo.value}(${actualTime})`; delaySpan.classList.add('ns-stacked-container'); } else { delaySpan.innerHTML = `${delayInfo.sign}${delayInfo.value}(${actualTime})`; } delaySpan.title = `Scheduled: ${timeText} → Actual: ${actualTime}`; } delayEl.dataset.nsConverted = 'true'; }); } function init() { const style = document.createElement('style'); style.textContent = ` .ns-actual-time { color: #0063d3 !important; font-weight: 600 !important; font-size: 0.9em; margin-left: 2px; white-space: nowrap; } .ns-stacked-container { display: flex !important; flex-direction: column !important; align-items: flex-start !important; line-height: 1.3 !important; } .ns-delay-row { display: block !important; white-space: nowrap !important; } .ns-actual-time.ns-stacked { display: block !important; margin-left: 0 !important; margin-top: 2px; font-size: 0.85em; } .stop-details-grid { row-gap: 16px !important; } .nes-col-start-1:has(rio-jp-delay) { min-height: 45px !important; padding-bottom: 8px !important; } .nes-flex-col:has(rio-jp-delay) { padding-bottom: 10px !important; } `; document.head.appendChild(style); const runTimes = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 7000, 10000]; runTimes.forEach(delay => setTimeout(processDelays, delay)); const observer = new MutationObserver(() => { setTimeout(processDelays, 100); setTimeout(processDelays, 500); }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();