Greasy Fork is available in English.

Kadaotery Time Tracker

Adds a header with a countdown to the next window where a refresh could occur. Allows you to update the refresh interval by inputting the last known refresh time. Supports desktop notifications to alert you when a new potential feeding window is about to occur.

// ==UserScript==
// @name         Kadaotery Time Tracker
// @version      1.34
// @description  Adds a header with a countdown to the next window where a refresh could occur. Allows you to update the refresh interval by inputting the last known refresh time. Supports desktop notifications to alert you when a new potential feeding window is about to occur.
// @author       darknstormy
// @match        http*://*.neopets.com/games/kadoatery/*
// @icon         https://images.neopets.com/games/kadoatery/island_happy.gif
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// @namespace https://greasyfork.org/users/1328929
// ==/UserScript==
/* eslint-env jquery */

/**
 * Stored data keys
 */
const LAST_REFRESH_TIME_KEY = "lastRefreshTime"
const NOTIFICATION_OPT_IN_KEY = "notificationOptIn"
const HUNGRY_KADS_KEY = "hungryCount"

const DURATION_UNTIL_START_OF_MAIN_WINDOW_MS = 1680000 // 28 minutes
const DURATION_OF_MAIN_WINDOW_MS = 90000
const DURATION_OF_PENDING_WINDOWS_MS = 60000
const ONE_SECOND_IN_MS = 1000;
const MAXIMUM_TIME_BETWEEN_REFRESHES_MS = 4680000

/**
 * Timers - stored globally so we can clean them up if they are no longer valid
 */
var countdownInterval
var notificationTimeout

runScript()

function runScript() {
    hideHeaderText()

    addIdToKadaotiesTable()
    addMissingRefreshTimeAlert()
    addCountdownText()
    addMainRefreshTimestampText()
    addUpdateRefreshTimeInput()

    showCountdownForNextFeeding()
}

/*
 * UI Changes (hiding and adding elements for the script to operate with)
 */
function hideHeaderText() {
    let textContainer = $(':contains("The Kadoatery")')
    textContainer.contents().filter(function() {
        return this.nodeType===3;
    }).remove();

    $(textContainer).children('br').hide()
}

function addIdToKadaotiesTable() {
    let kadaotiesTableJquery = $('.content div table').first()
    kadaotiesTableJquery.attr("id","kadaotiesTable");
}

function addMissingRefreshTimeAlert() {
    $("<div id='windowMissingAlert' style='background: red; color: white; padding: 4px'><h1>Next refresh window missing!</h1><p>Windows cannot be calculated because the last refresh time is missing or out of date. Please update using the textbox below to start the timer.</p></div>")
        .insertBefore('#kadaotiesTable')
}

function addCountdownText() {
    $(`<div id="countdownText"></div>`).insertBefore('#kadaotiesTable')
}

function addMainRefreshTimestampText() {
    $(`<div id='lastKnownMainTimestamp' style="display: block; text-align: center;"><p>Last known main refresh occurred at <span id="mainWindowTime"></span> local time.</p></div>`)
        .insertAfter("#kadaotiesTable")
}

function addUpdateRefreshTimeInput() {
    $(`<div id="refreshTimeContainer" style="margin: 8px 0;"><label for="refreshTime" style="display: block; font-weight: bold;">Update Main Refresh Time</label><input type="text" id="refreshTimeInput" name="refreshTime" minlength="5" maxlength="8" placeholder="HH:MM:SS" style="margin: 4px; display: inline-block;"/><button id="updateRefreshTimestampBtn">Submit</button></div>`)
        .insertAfter("#lastKnownMainTimestamp")
    $("#updateRefreshTimestampBtn").on("click", updateLastRefreshedTime)
}

/*
 * Helper functions
 */
function now() {
    return new Date().getTime()
}

function isNumeric(str) {
  if (typeof str != "string") return false // we only process strings!
  return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
         !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail
}

function validNumberInRange(value, minInclusive, maxInclusive) {
    return isNumeric(value) && value <= maxInclusive && value >= minInclusive
}

function stopTimers() {
    if (countdownInterval) {
        clearInterval(countdownInterval)
    }

    if (notificationTimeout) {
        clearTimeout(notificationTimeout)
    }
}

function addMinutes(date, minutes) {
    return new Date(date.getTime() + minutes*60000).getTime();
}

/**
 * Formatting functions to make things pretty :)
 */
function formatTwoDigits(n) {
    return n < 10 ? '0' + n : n;
}

function formatCountdown(d) {
    let minutes = formatTwoDigits(d.getMinutes());
    let seconds = formatTwoDigits(d.getSeconds());
    return minutes + ":" + seconds;
}

function formatWindowTime(d) {
    let hours = formatTwoDigits(d.getHours());
    let minutes = formatTwoDigits(d.getMinutes());
    let seconds = formatTwoDigits(d.getSeconds());
    return hours + ":" + minutes + ":" + seconds;
}

/**
 * Functions to do with the last main refresh time - validating, saving, inputting, etc
 */
function saveLastRefreshTime(date) {
    GM_setValue(LAST_REFRESH_TIME_KEY, date.getTime())
    $("#mainWindowTime").html(formatWindowTime(date))
    showCountdownForNextFeeding()
}

function hasValidLastRefreshTime() {
    let lastRefreshTime = getLastRefreshTime()
    let currentTime = now()

    if (!lastRefreshTime || lastRefreshTime > currentTime || ((currentTime - lastRefreshTime) > MAXIMUM_TIME_BETWEEN_REFRESHES_MS)) {
        $('#lastKnownMainTimestamp').hide()
        $('#countdownText').hide()
        $('#windowMissingAlert').show()
        return false
    } else {
        $('#lastKnownMainTimestamp').show()
        $('#countdownText').show()
        $('#windowMissingAlert').hide()
        $("#mainWindowTime").html(formatWindowTime(new Date(lastRefreshTime)))
        return true
    }
}

function updateLastRefreshedTime() {
    let inputtedTimes = $("#refreshTimeInput").val().split(":")

    let validationErrors = []

    if (inputtedTimes.length < 2) {
        validationErrors.push("You must supply at least the hour and minutes, with optional seconds, separated by a colon (##:##:##).");
    }

    let hour = inputtedTimes[0]

    if (!validNumberInRange(hour, 0, 23)) {
        validationErrors.push("Hour should follow 24 hour format and be a number between 0 and 23.")
    }

    let min = inputtedTimes[1]

    if (!validNumberInRange(min, 0, 59)) {
        validationErrors.push("Minutes must be a number between 0 and 59.")
    }

    // Default "seconds" input to 00 if it was not included
    var sec = "00"

    if (inputtedTimes.length > 2) {
        sec = inputtedTimes[2]
    }

    if (!validNumberInRange(sec, 0, 59)) {
        validationErrors.push("Seconds must be a number between 0 and 59.")
    }

    if (validationErrors.length > 0) {
        window.alert("Inputted refresh time was not correctly formatted. " + validationErrors.join(" "))
        return
    }

    var inputtedTime = new Date()

    // are we past midnight? there's a weird edge case where we have to fix the day here
    if (inputtedTime.getHours() <= 1 && hour === "23") {
        inputtedTime = new Date(inputtedTime.getTime() - 86400000) // get yesterday's date. We'll set all the values that matter after this.
    }

    inputtedTime.setHours(hour)
    inputtedTime.setMinutes(min)
    inputtedTime.setSeconds(sec)

    let currentTime = now()

    if (inputtedTime.getTime() > currentTime || (currentTime - inputtedTime.getTime()) > MAXIMUM_TIME_BETWEEN_REFRESHES_MS) {
        console.log("not valid input")
        window.alert("Inputted refresh time must be within the previous 78 minutes to be considered valid. Please try again.")
        return
    }

    saveLastRefreshTime(inputtedTime)
}

function getLastRefreshTime() {
    return GM_getValue(LAST_REFRESH_TIME_KEY)
}

/**
 * Functions to enable desktop notifications
 */
function addNotificationSupport(refreshAfter) {
    let optedInForNotifications = GM_getValue(NOTIFICATION_OPT_IN_KEY)

    if (optedInForNotifications) {
        notifyForNextWindow(refreshAfter)
    } else if ("Notification" in window && typeof optedInForNotifications === "undefined") {
        // The user has never opted in for notifications, so we need to ask for permission.
        $("#countdownText").append('<button id="notify" style="margin: 0px 0px 8px 0px;">Notify Me</button>')
        $('#notify')[0].onclick = function () {
             Notification.requestPermission().then((permission) => {
                 GM_setValue(NOTIFICATION_OPT_IN_KEY, permission === "granted")
                 addNotificationSupport(refreshAfter)
             })
        }
    }
}

function notifyForNextWindow(msTilNextWindowStart) {
    $('#notify').hide()
    $("#countdownText").append('<p style="font-weight: bold; color: green;">Notifications are turned on.</p>')

    // Give 10 seconds' heads up with the notification, if there are > 10 seconds remaining from the time the user requested the notification.
    // This allows for delay in the notification system so that you don't get notified too late and miss out potentially.
    if (msTilNextWindowStart > 10000) {
        notificationTimeout = setTimeout(function() {
            showNotification("Kadaotie Time Tracker", "A new refresh window is starting! Check for unfed Kadaoties now.")
        }, msTilNextWindowStart - 10000);
    } else {
        showNotification("Kadaotie Time Tracker", "A new refresh window is starting! Check for unfed Kadaoties now.")
    }
}

function showNotification(title, body) {
    let notification = new Notification(title, {
        body: body,
        icon: "https://images.neopets.com/games/kadoatery/island_happy.gif" })
    notification.onclick = () => {
        notification.close()
        window.focus()
    }
}

/**
 * The meat of the Kadaotie Time Tracking Logic starts here
 */
function checkForKadaotieRefresh() {
    let hungryKadaoties = $("#kadaotiesTable td:contains('is very sad')").length
    let previouslyHungryKadaoties = GM_getValue(HUNGRY_KADS_KEY, 0)
    GM_setValue(HUNGRY_KADS_KEY, hungryKadaoties)

    // If we have more hungry kadaoties than we stored previously...
    if (hungryKadaoties > previouslyHungryKadaoties) {
        // There's been a turnover.
        saveLastRefreshTime(new Date())

        if (GM_getValue(NOTIFICATION_OPT_IN_KEY)) {
            showNotification("HUNGRY KADAOTIES ARE WAITING!", "Hurry up and feed them before someone else does!")
        }
        return true
    }

    return false
}

function showCountdownForNextFeeding() {
    stopTimers() // In case we've had an invalidated window (because of user update), clear out any existing timers. They'll be started again when needed.

    if (!hasValidLastRefreshTime()) {
        return
    }

    let refreshed = checkForKadaotieRefresh()

    let mainWindow = getMainWindow()
    let pendingWindows = getPendingWindows()

    if (!refreshed) {
        let timeRemainingInWindow = getTimeRemainingInRefreshWindow(mainWindow, pendingWindows)
        if (timeRemainingInWindow > 0) {
            showPotentialWindowAlert(timeRemainingInWindow)
            return
        }
    }

    showCountdownToNextWindow(getNextWindowTime([mainWindow, ...pendingWindows]))
}

function showPotentialWindowAlert(timeRemainingInWindow) {

    var timeRemainingInSeconds = Math.round(timeRemainingInWindow / 1000)

    if (timeRemainingInSeconds == 0) {
        showCountdownToNextWindow(getNextWindowTime([getMainWindow(), ...getPendingWindows()]))
        return
    }

    $('#countdownText')[0].replaceChildren()
    $('#countdownText').append(`<h1 style="color: red">We're within the window for a refresh (<span id="secondsRemaining" style="color: green">${timeRemainingInSeconds}</span> seconds remain)! Keep refreshing for hungry Kadaoties!</h1><div>`);

    countdownInterval = setInterval(function() {
        $("#secondsRemaining").html(--timeRemainingInSeconds);
    }, ONE_SECOND_IN_MS)

    setTimeout(function() {
       showCountdownForNextFeeding()
   }, timeRemainingInWindow);
}

function showCountdownToNextWindow(nextWindow) {
    let nextWindowTime = formatWindowTime(new Date(nextWindow))
    var countdownToRefresh = nextWindow - now()

    $('#countdownText')[0].replaceChildren()
    $('#countdownText').append(`<h1>The next potential refresh time window begins at <span id='nextEstimatedFeeding' style='color: red'>${nextWindowTime}</span> (Local Time). You should begin refreshing in <span id="minutes" style='color: red'>${formatCountdown(new Date(countdownToRefresh))}</span> minutes.</h1></div>`);

    addNotificationSupport(countdownToRefresh)

    countdownInterval = setInterval(function() {
        countdownToRefresh = countdownToRefresh - 1000
        let timeRemaining = formatCountdown(new Date(countdownToRefresh))
        $("#minutes").html(timeRemaining);
    }, ONE_SECOND_IN_MS)

   setTimeout(function() {
       showCountdownForNextFeeding()
   }, countdownToRefresh);
}

function getMainWindow() {
    return getLastRefreshTime() + DURATION_UNTIL_START_OF_MAIN_WINDOW_MS
}

function getPendingWindows() {
    // Potential later windows could be every 7 minutes after the first window, so we will check for 1 minute each time
    var mainWindow = new Date(getMainWindow())

    var windows = new Array()

    for (var i = 1; i < 8; i++) {
        let pendingWindow = addMinutes(mainWindow, i * 7)
        windows.push(pendingWindow)
    }

    return windows
}

function getNextWindowTime(windows) {
    var currentTime = now()
    return windows.find((window) => currentTime <= window)
}

function getTimeRemainingInRefreshWindow(mainWindow, pendingWindows) {
    var currentTime = now()

    if (currentTime > mainWindow && currentTime < mainWindow + DURATION_OF_MAIN_WINDOW_MS) {
        return mainWindow + DURATION_OF_MAIN_WINDOW_MS - currentTime
    }

    var inWindow = pendingWindows.find((window) => currentTime >= window && currentTime < window + DURATION_OF_PENDING_WINDOWS_MS)

    if (inWindow) {
        return inWindow + DURATION_OF_PENDING_WINDOWS_MS - currentTime
    }

    return -1
}