Next Spaceflight Countdown

Adds a countdown timer to upcoming space launches on NextSpaceflight.com (uses MutationObserver and fetches detail pages for reliable timestamps)

// ==UserScript==
// @name         Next Spaceflight Countdown
// @description  Adds a countdown timer to upcoming space launches on NextSpaceflight.com (uses MutationObserver and fetches detail pages for reliable timestamps)
// @match        https://nextspaceflight.com/*
// @version 0.0.1.20251012082016
// @namespace https://greasyfork.org/users/1435046
// ==/UserScript==

(function() {
    'use strict';

    // Map of month names to month indices (for parsing textual dates)
    const monthNameToIndex = {
        January: 0, February: 1, March: 2, April: 3, May: 4, June: 5,
        July: 6, August: 7, September: 8, October: 9, November: 10, December: 11
    };

    // Class name for inserted countdown elements
    const countdownElementClassName = 'nsf-countdown';

    // Mark processed anchors to avoid double-processing
    const processedDataAttributeName = 'data-nsf-countdown-processed';

    // Insert a countdown DOM node into the provided containerElement and return that node
    function createAndAttachCountdownNode(containerElement) {
        const countdownElement = document.createElement('div');
        countdownElement.className = countdownElementClassName;
        countdownElement.style.marginTop = '6px';
        countdownElement.style.padding = '4px 6px';
        countdownElement.style.borderRadius = '6px';
        countdownElement.style.fontSize = '13px';
        countdownElement.style.background = 'rgba(0,0,0,0.35)';
        countdownElement.style.color = 'rgb(209, 205, 199)';
        countdownElement.style.display = 'inline-block';
        countdownElement.textContent = 'Loading countdown...';
        containerElement.appendChild(countdownElement);
        return countdownElement;
    }

    // Given a detail page HTML string, extract an approximate UTC/GMT launch timestamp in milliseconds.
    // Strategy: look for a time (HH:MM or HH:MM:SS) followed nearby by a textual date "Month Day, Year".
    function extractLaunchTimestampFromDetailHtml(detailHtmlText) {
        const textContent = detailHtmlText.replace(/\r/g, ' ').replace(/\n/g, ' ');
        // Regex: time (HH:MM or HH:MM:SS)
        const timeRegex = /([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?/;
        // Regex: textual date like "January 15, 2025" or "October 16, 01:30" (we want Month Day, Year)
        const dateRegex = /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+([1-9]|[12]\d|3[01]),\s*(\d{4})\b/;

        const timeMatch = textContent.match(timeRegex);
        const dateMatch = textContent.match(dateRegex);

        // If the details page explicitly labels "Liftoff Time (GMT)" the time/date found are in GMT/UTC; use UTC.
        if (!timeMatch || !dateMatch) {
            return null;
        }

        const hour = parseInt(timeMatch[1], 10);
        const minute = parseInt(timeMatch[2], 10);
        const second = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;

        const monthName = dateMatch[1];
        const dayNumber = parseInt(dateMatch[2], 10);
        const yearNumber = parseInt(dateMatch[3], 10);

        const monthIndex = monthNameToIndex[monthName];

        // Construct a UTC timestamp because the details page shows liftoff in GMT.
        const timestampMilliseconds = Date.UTC(yearNumber, monthIndex, dayNumber, hour, minute, second);
        return timestampMilliseconds;
    }

    // Given a timestamp in ms, format T-/T+ string with days/hours/minutes/seconds
    function formatTimeDeltaToCountdownText(launchTimestampMilliseconds) {
        const nowMilliseconds = Date.now();
        const diffMilliseconds = launchTimestampMilliseconds - nowMilliseconds;
        const prefix = diffMilliseconds < 0 ? 'T+' : 'T-';
        const absoluteDiff = Math.abs(diffMilliseconds);

        const secondsTotal = Math.floor(absoluteDiff / 1000);
        const days = Math.floor(secondsTotal / (24 * 3600));
        const hours = Math.floor((secondsTotal % (24 * 3600)) / 3600);
        const minutes = Math.floor((secondsTotal % 3600) / 60);
        const seconds = secondsTotal % 60;

        return `${prefix}${days}d ${hours}h ${minutes}m ${seconds}s`;
    }

    // Update all countdown elements on the page (single centralized updater)
    function updateAllCountdownNodes() {
        const countdownNodes = document.querySelectorAll('.' + countdownElementClassName);
        countdownNodes.forEach(node => {
            const launchTimestampString = node.getAttribute('data-launch-timestamp-ms');
            if (!launchTimestampString) return;
            const launchTimestampMilliseconds = parseInt(launchTimestampString, 10);
            const countdownText = formatTimeDeltaToCountdownText(launchTimestampMilliseconds);
            node.textContent = countdownText;
        });
    }

    // Process a single anchor element pointing to a launch details page
    function processLaunchAnchorElement(anchorElement) {
    // Only process launch detail anchors
    const href = anchorElement.getAttribute('href') || anchorElement.href;
    if (!href || !href.includes('/launches/details/')) return;

    // Avoid re-processing
    if (anchorElement.hasAttribute(processedDataAttributeName)) return;
    anchorElement.setAttribute(processedDataAttributeName, 'true');

    // Skip if this post already includes a countdown timer
    // Detection heuristic: look for any element that includes the text HOURS, MINS, or SECS inside this anchor
    const hasBuiltInCountdown = !!anchorElement.querySelector('p, div, span');
    if (hasBuiltInCountdown) {
        const textContent = anchorElement.textContent.toUpperCase();
        if (textContent.includes('HOURS') || textContent.includes('MINS') || textContent.includes('SECS')) {
            return; // skip adding custom countdown
        }
    }

    // Decide where to put the countdown visually: prefer a container inside the anchor that likely holds the metadata
    let insertionContainer = anchorElement.querySelector('div[class*="p-4"], div[class*="gap-2"], div[class*="pb-4"]');
    if (!insertionContainer) insertionContainer = anchorElement;

    // create node and attach
    const countdownNode = createAndAttachCountdownNode(insertionContainer);

    // Fetch the details page and attempt to extract a reliable timestamp
    const resolvedUrl = anchorElement.href;

    fetch(resolvedUrl, { method: 'GET', credentials: 'same-origin' })
        .then(response => response.text())
        .then(detailHtmlText => {
            const launchTimestampMilliseconds = extractLaunchTimestampFromDetailHtml(detailHtmlText);
            if (!launchTimestampMilliseconds) {
                countdownNode.textContent = 'Countdown unavailable';
                return;
            }
            countdownNode.setAttribute('data-launch-timestamp-ms', String(launchTimestampMilliseconds));
            const formattedText = formatTimeDeltaToCountdownText(launchTimestampMilliseconds);
            countdownNode.textContent = formattedText;
        });
}


    // Find all existing launch anchor elements and process them
    function processAllExistingLaunchAnchors() {
        // Select anchors that link to launch details on the site
        const candidateAnchors = Array.from(document.querySelectorAll('a[href*="/launches/details/"]'));
        candidateAnchors.forEach(anchor => processLaunchAnchorElement(anchor));
    }

    // Observe the DOM for added nodes that may contain new launch anchors (event-driven)
    const mutationObserver = new MutationObserver(mutationRecords => {
        mutationRecords.forEach(record => {
            // Look for newly added nodes (subtree additions) and process any anchor descendants
            record.addedNodes.forEach(addedNode => {
                if (!(addedNode instanceof Element)) return;
                // If the added node itself is a launch anchor
                if (addedNode.matches && addedNode.matches('a[href*="/launches/details/"]')) {
                    processLaunchAnchorElement(addedNode);
                }
                // Or if the added node contains launch anchors deeper in the subtree
                const nestedLaunchAnchors = addedNode.querySelectorAll ? addedNode.querySelectorAll('a[href*="/launches/details/"]') : [];
                nestedLaunchAnchors.forEach(nestedAnchor => processLaunchAnchorElement(nestedAnchor));
            });
        });
    });

    // Start observing document body for additions
    mutationObserver.observe(document.body, { childList: true, subtree: true });

    // Initial pass for anchors already present on load
    processAllExistingLaunchAnchors();

    // Single shared timer that updates visible countdowns once a second
    // Using a single timer minimizes overhead and satisfies "single centralized updater" style
    // Immediately update countdowns
updateAllCountdownNodes();

// Function to synchronize updates with system time
function scheduleSynchronizedUpdate() {
    // Run the update
    updateAllCountdownNodes();

    // Compute delay until the next full second boundary
    const nowMilliseconds = Date.now();
    const millisecondsUntilNextSecond = 1000 - (nowMilliseconds % 1000);

    // Schedule the next synchronized update
    setTimeout(scheduleSynchronizedUpdate, millisecondsUntilNextSecond);
}

// Align countdown updates precisely with system time
scheduleSynchronizedUpdate();


})();