Kadoatery refresh tracker — Main/Mini timers, board copy, lock detection, food logging.
// ==UserScript==
// @name KadWatch
// @version 0.31
// @description Kadoatery refresh tracker — Main/Mini timers, board copy, lock detection, food logging.
// @author Ryan (ext1nct)
// @match http*://*.neopets.com/games/kadoatery/*
// @icon https://itemdb.com.br/api/cache/preview/7f18f78e35daa6.png
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// @namespace https://greasyfork.org/users/custom
// ==/UserScript==
/* eslint-env jquery */
/**
* Stored data keys
*/
const LAST_REFRESH_TIME_KEY = "lastRefreshTime"
const MAIN_KAD_DATA_KEY = "mainKadData"
const MAIN_HISTORY_KEY = "mainHistory"
const MINI_REFRESH_TIMES_KEY = "miniRefreshTimes"
const KAD_STATES_KEY = "kadStates"
const NOTIFICATION_OPT_IN_KEY = "notificationOptIn"
const KAD_FIRST_RUN_KEY = "kadFirstRun"
const PENDING_DROP_KEY = "pendingDrop"
const DURATION_UNTIL_START_OF_MAIN_WINDOW_MS = 2100000 // 35 minutes
const DURATION_OF_MAIN_WINDOW_MS = 60000 // 60 seconds
const DURATION_OF_PENDING_WINDOWS_MS = 60000 // 60 seconds
const MAXIMUM_TIME_BETWEEN_REFRESHES_MS = 4680000
let notificationsTriggered = { main: 0, minis: {} };
let userIsLockedOut = false;
function censorFood(name) {
return name
.replace(/ball/gi, "b.all")
.replace(/crack/gi, "crac.k")
.replace(/weed/gi, "w.eed")
.replace(/rape/gi, "r.ape")
.replace(/cum/gi, "c.um");
}
// Inject KadWatch CSS
const STYLES = `
<style>
.kad-modern-wrapper { margin: 8px auto 16px auto; max-width: 850px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: #334155; }
.kad-panel { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
.kad-alert { background: #fef2f2; color: #991b1b; padding: 6px; border-radius: 4px; border: 1px solid #fecaca; margin-bottom: 6px; text-align: center; font-weight: 500; font-size: 12px; }
.kad-alert-warning { background: #fef9c3; color: #854d0e; border-color: #fde047; }
.kad-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; margin-bottom: 2px; }
.kad-toolbar-group { display: flex; align-items: center; gap: 8px; }
.kad-tracker-block { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px 12px 12px 12px; position: relative; margin-top: 4px; display: flex; flex-direction: column; align-items: center; }
.kad-tracker-block.kad-main-block { border-color: #f0b8cc; background: #fdf5f8; }
.kad-block-label { position: absolute; top: 6px; left: 10px; font-weight: 700; font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; max-width: calc(100% - 160px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kad-last-refresh { font-size: 11px; color: #94a3b8; margin-top: 4px; }
.kad-countdown-box { font-size: 18px; font-weight: 700; color: #0f172a; margin-top: 6px; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; }
.kad-time-hl { color: #ef4444; font-size: 22px; font-variant-numeric: tabular-nums; letter-spacing: 0.5px; }
.kad-time-sub { font-size: 13px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.kad-divider { color: #cbd5e1; font-weight: 300; margin: 0 4px; }
.kad-window-active { color: #15803d; font-size: 22px; font-weight: 700; }
.kad-btn { background: #c084a0; color: #fff; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-weight: 600; transition: background 0.15s; font-size: 12px; }
.kad-btn:hover { background: #a06080; }
.kad-btn-teal { background: #5b9fc7; }
.kad-btn-teal:hover { background: #4180a8; }
.kad-btn-red { background: #c0606a; padding: 2px 6px; font-size: 11px; }
.kad-btn-red:hover { background: #a04858; }
.kad-btn-action { position: absolute; top: 6px; right: 28px; padding: 2px 6px; font-size: 10px; background: #9b7fa8; }
.kad-btn-action:hover { background: #7a6088; }
.kad-btn-remove { position: absolute; top: 6px; right: 6px; padding: 2px 6px; font-size: 10px; }
.kad-input { border: 1px solid #cbd5e1; border-radius: 4px; padding: 3px 6px; text-align: center; font-size: 12px; width: 65px; outline: none; background: #f8fafc; transition: border 0.15s; }
.kad-input:focus { border-color: #c084a0; background: #ffffff; }
.kad-input-error { border-color: #b84c23 !important; background: #fff5f5 !important; }
.kad-input-hint { font-size: 11px; color: #b84c23; margin-left: 2px; display: none; }
.kad-quick-links a { color: #64748b; text-decoration: none; font-weight: 600; font-size: 12px; transition: color 0.15s; }
.kad-quick-links a:hover { color: #0f172a; }
.kad-food-copy { cursor: pointer; color: #334155 !important; border-bottom: 1px dotted #94a3b8; transition: all 0.15s; }
.kad-food-copy:hover { color: #0f172a !important; background: #f1f5f9; border-bottom: 1px solid #0f172a; }
</style>
`;
runScript()
function runScript() {
$('head').append(STYLES);
hideHeaderText();
addIdToKadaotiesTable();
injectDashboard();
checkBoardLockState();
addClickToCopyFeature();
setupNotificationSupport();
checkForKadaotieRefresh();
setInterval(tickTimers, 1000);
tickTimers();
}
function hideHeaderText() {
let textContainer = $(':contains("The Kadoatery")');
textContainer.contents().filter(function() { return this.nodeType===3; }).remove();
$(textContainer).children('br').hide();
}
function addIdToKadaotiesTable() {
$('.content div table').first().attr("id","kadaotiesTable");
}
function injectDashboard() {
let dashboardHtml = `
<div id="kad-dashboard-wrapper" class="kad-modern-wrapper">
<div id='alreadyFedAlert' class='kad-alert kad-alert-warning' style='display:none;'>
⚠️ <strong>You are locked out!</strong> A Mini refreshed, but your previously fed Kad is still on the board. Do not buy!
</div>
<div id='windowMissingAlert' class='kad-alert' style='display:none;'>
Main refresh time missing or expired.
</div>
<div id='pendingDropAlert' class='kad-alert kad-alert-warning' style='display:none;'></div>
<div id="kad-dashboard" class="kad-panel">
<div class="kad-toolbar">
<div class="kad-toolbar-group kad-quick-links">
<a href="/market.phtml?type=wizard" target="_blank">SW</a> • <a href="/safetydeposit.phtml" target="_blank">SDB</a>
</div>
<div class="kad-toolbar-group" style="border-left: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; padding: 0 10px;">
<input type="text" id="manualTimeInput" class="kad-input" placeholder="HH:MM" /><span id="timeInputHint" class="kad-input-hint"></span>
<button id="setMainBtn" class="kad-btn">Log Main</button>
<button id="addMiniBtn" class="kad-btn">Log Mini</button>
</div>
<div class="kad-toolbar-group">
<span id="notifyStatus" style="font-size: 11px; color: #10b981; display: none; font-weight: 600;">🔔 ON</span>
<button id="enableNotifyBtn" class="kad-btn" style="display: none;">🔔 Enable Alerts</button>
<button id="copyBoardBtn" class="kad-btn kad-btn-teal">📋 Copy Post</button>
</div>
</div>
<div id="mainContainer"></div>
<div id="minisContainer" style="display: flex; flex-direction: column; gap: 4px;"></div>
</div>
</div>
`;
$(dashboardHtml).insertBefore('#kadaotiesTable');
$("#setMainBtn").on("click", () => handleManualTime('main'));
$("#addMiniBtn").on("click", () => handleManualTime('mini'));
$("#copyBoardBtn").on("click", copyBoardTimes);
$(document).on("click", ".demote-main-btn", demoteMainToMini);
$(document).on("click", ".promote-mini-btn", function() { promoteMiniToMain($(this).data('index')); });
$(document).on("click", ".remove-main-btn", function() { GM_setValue(LAST_REFRESH_TIME_KEY, 0); tickTimers(); });
$(document).on("click", ".remove-mini-btn", function() { removeMini($(this).data('index')); });
$(document).on("click", ".merge-main-btn", function() { mergePendingDrop('main'); });
$(document).on("click", ".merge-mini-btn", function() { mergePendingDrop('mini', $(this).data('index')); });
$(document).on("click", "#discardDropBtn", discardPendingDrop);
}
function checkBoardLockState() {
let username = "";
let modernNav = $('.nav-profile-dropdown-text').first().text().trim();
if (modernNav) username = modernNav;
else {
let classicHref = $("a[href^='/userlookup.phtml?user=']").first().attr("href");
if (classicHref) {
let match = classicHref.match(/user=([^&]+)/);
if (match) username = match[1];
}
}
userIsLockedOut = false;
if (username) {
let fedItText = username + " has been fed";
let hasHungryKads = false;
$("#kadaotiesTable td").each(function() {
let text = $(this).text();
if (text.includes(fedItText)) userIsLockedOut = true;
if (text.includes("is very sad")) hasHungryKads = true;
});
if (userIsLockedOut && hasHungryKads) $('#alreadyFedAlert').show();
else $('#alreadyFedAlert').hide();
}
}
function formatKadLabel(type, count, first, last) {
if (!count || count === 0) return type;
if (count === 1) return `${type} (1)${first}`;
return `${type} (${count})${first} › ${last}`;
}
function getCurrentKadStates() {
let states = {};
$("#kadaotiesTable td").slice(0, 20).each(function(index) {
let text = $(this).text();
let strongs = $(this).find('strong');
let kadName = strongs.first().text().trim();
if (text.includes("is very sad")) {
let foodName = strongs.length > 1 ? strongs.last().text().trim() : "";
states[index] = { name: kadName, food: foodName, status: "sad" };
} else if (text.includes("has been fed")) {
states[index] = { name: kadName, food: "", status: "fed" };
}
});
return states;
}
function checkForKadaotieRefresh() {
let currentState = getCurrentKadStates();
let prevStateString = GM_getValue(KAD_STATES_KEY, "{}");
let prevState = JSON.parse(prevStateString);
let isInitialLoad = GM_getValue(KAD_FIRST_RUN_KEY, true);
GM_setValue(KAD_FIRST_RUN_KEY, false);
let newlySadKads = [];
let newlySadIndices = new Set();
for (let i = 0; i < 20; i++) {
if (currentState[i] && currentState[i].status === "sad") {
let isNew = !prevState[i]
|| prevState[i].status !== "sad"
|| prevState[i].name !== currentState[i].name;
if (isNew) {
newlySadKads.push({ name: currentState[i].name, food: currentState[i].food || "" });
newlySadIndices.add(i);
}
}
}
GM_setValue(KAD_STATES_KEY, JSON.stringify(currentState));
let currentSadNames = new Set();
for (let i in currentState) {
if (currentState[i].status === "sad") currentSadNames.add(currentState[i].name);
}
const pruneTrackedFeeds = () => {
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e){}
if (mainData.names && mainData.names.length > 0) {
let prunedNames = [], prunedFoods = [];
for (let i = 0; i < mainData.names.length; i++) {
if (currentSadNames.has(mainData.names[i])) {
prunedNames.push(mainData.names[i]);
prunedFoods.push(mainData.foods[i]);
}
}
if (prunedNames.length !== mainData.names.length) {
mainData.names = prunedNames;
mainData.foods = prunedFoods;
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify(mainData));
}
}
let minis = getMinis();
let minisChanged = false;
for (let m = 0; m < minis.length; m++) {
if (minis[m].names && minis[m].names.length > 0) {
let prunedNames = [], prunedFoods = [];
for (let i = 0; i < minis[m].names.length; i++) {
if (currentSadNames.has(minis[m].names[i])) {
prunedNames.push(minis[m].names[i]);
prunedFoods.push(minis[m].foods[i]);
}
}
if (prunedNames.length !== minis[m].names.length) {
minis[m].names = prunedNames;
minis[m].foods = prunedFoods;
minisChanged = true;
}
}
}
if (minisChanged) saveMinis(minis);
};
if (isInitialLoad || newlySadKads.length === 0) {
pruneTrackedFeeds();
return;
}
let now = new Date().getTime();
const COALESCE_WINDOW_MS = 240000; // 4 minutes
let mainTime = GM_getValue(LAST_REFRESH_TIME_KEY, 0);
let minisArr = getMinis();
let mostRecentTime = mainTime;
let mostRecentType = 'main';
let mostRecentIndex = -1;
minisArr.forEach((m, i) => {
if (m.time > mostRecentTime) {
mostRecentTime = m.time;
mostRecentType = 'mini';
mostRecentIndex = i;
}
});
if (mostRecentTime > 0 && (now - mostRecentTime < COALESCE_WINDOW_MS)) {
if (mostRecentType === 'main') {
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e){}
let existingNamesSet = new Set(mainData.names || []);
let mergedNames = [...(mainData.names || [])];
let mergedFoods = [...(mainData.foods || [])];
let added = false;
for (let k of newlySadKads) {
if (!existingNamesSet.has(k.name)) {
mergedNames.push(k.name);
mergedFoods.push(k.food);
added = true;
}
}
if (added) {
let count = mergedNames.length;
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify({
count, first: mergedNames[0], last: mergedNames[count - 1],
names: mergedNames, foods: mergedFoods
}));
pruneTrackedFeeds();
tickTimers();
}
} else {
let mini = minisArr[mostRecentIndex];
let existingNamesSet = new Set(mini.names || []);
let mergedNames = [...(mini.names || [])];
let mergedFoods = [...(mini.foods || [])];
let added = false;
for (let k of newlySadKads) {
if (!existingNamesSet.has(k.name)) {
mergedNames.push(k.name);
mergedFoods.push(k.food);
added = true;
}
}
if (added) {
let count = mergedNames.length;
minisArr[mostRecentIndex] = { ...mini, count, first: mergedNames[0], last: mergedNames[count - 1], names: mergedNames, foods: mergedFoods };
saveMinis(minisArr);
pruneTrackedFeeds();
}
}
return;
}
let existing = {};
try { existing = JSON.parse(GM_getValue(PENDING_DROP_KEY, "{}")); } catch(e) {}
if (existing.time && (now - existing.time < COALESCE_WINDOW_MS)) {
let existingNamesSet = new Set(existing.names || []);
for (let k of newlySadKads) {
if (!existingNamesSet.has(k.name)) {
existing.names.push(k.name);
existing.foods.push(k.food);
}
}
existing.count = existing.names.length;
GM_setValue(PENDING_DROP_KEY, JSON.stringify(existing));
renderPendingDrop();
pruneTrackedFeeds();
return;
}
function countMissingNames(storedNames) {
if (!storedNames || storedNames.length === 0) return 0;
return storedNames.filter(name => !currentSadNames.has(name)).length;
}
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e) {}
let minis = getMinis();
let mainTotal = (mainData.names || []).length;
let mainMissing = countMissingNames(mainData.names);
let mainFullyRefreshed = mainTotal > 0 && mainMissing === mainTotal;
let mainPartiallyRefreshed = mainTotal > 0 && mainMissing > 0 && mainMissing < mainTotal;
let miniResults = minis.map((mini, i) => {
let total = (mini.names || []).length;
let missing = countMissingNames(mini.names);
return {
index: i,
fullyRefreshed: total > 0 && missing === total,
partiallyRefreshed: total > 0 && missing > 0 && missing < total
};
});
let fullyRefreshedMiniIndices = miniResults.filter(r => r.fullyRefreshed).map(r => r.index);
let partiallyRefreshedMiniIndices = miniResults.filter(r => r.partiallyRefreshed).map(r => r.index);
let kadNames = newlySadKads.map(k => k.name);
let kadFoods = newlySadKads.map(k => k.food);
let count = kadNames.length;
if (mainFullyRefreshed && fullyRefreshedMiniIndices.length > 0) {
pushMainToHistory();
GM_setValue(LAST_REFRESH_TIME_KEY, now);
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify({
count, first: kadNames[0], last: kadNames[count - 1],
names: kadNames, foods: kadFoods
}));
let updatedMinis = minis.filter((_, i) => !fullyRefreshedMiniIndices.includes(i));
saveMinis(updatedMinis);
pruneTrackedFeeds();
tickTimers();
return;
}
if (mainFullyRefreshed) {
pushMainToHistory();
GM_setValue(LAST_REFRESH_TIME_KEY, now);
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify({
count, first: kadNames[0], last: kadNames[count - 1],
names: kadNames, foods: kadFoods
}));
pruneTrackedFeeds();
tickTimers();
return;
}
if (fullyRefreshedMiniIndices.length > 0) {
let updatedMinis = [...minis];
fullyRefreshedMiniIndices.forEach((miniIdx, pos) => {
if (pos === 0) {
updatedMinis[miniIdx] = { ...updatedMinis[miniIdx], time: now, count,
first: kadNames[0], last: kadNames[count - 1], names: kadNames, foods: kadFoods };
} else {
updatedMinis[miniIdx] = { ...updatedMinis[miniIdx], time: now };
}
});
saveMinis(updatedMinis);
pruneTrackedFeeds();
tickTimers();
return;
}
if (mainPartiallyRefreshed || partiallyRefreshedMiniIndices.length > 0) {
addMini(now, count, kadNames[0], kadNames[count - 1], kadNames, kadFoods);
pruneTrackedFeeds();
tickTimers();
return;
}
GM_setValue(PENDING_DROP_KEY, JSON.stringify({
time: now,
count: kadNames.length,
names: kadNames,
foods: kadFoods,
indices: [...newlySadIndices],
match: null
}));
renderPendingDrop();
pruneTrackedFeeds();
}
function identifyRefreshedFeed(currentState) {
let currentSadNames = new Set();
for (let i in currentState) {
if (currentState[i].status === "sad") currentSadNames.add(currentState[i].name);
}
function feedHasRefreshed(storedNames) {
if (!storedNames || storedNames.length === 0) return false;
return storedNames.some(name => !currentSadNames.has(name));
}
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e) {}
let minis = getMinis();
if (feedHasRefreshed(mainData.names)) return { type: 'main', label: 'Main' };
for (let i = 0; i < minis.length; i++) {
if (feedHasRefreshed(minis[i].names)) return { type: 'mini', index: i, label: `Mini ${i + 1}` };
}
return null;
}
function renderPendingDrop() {
let drop = {};
try { drop = JSON.parse(GM_getValue(PENDING_DROP_KEY, "{}")); } catch(e) {}
let alert = $('#pendingDropAlert');
if (!drop.time) { alert.hide().empty(); return; }
let match = drop.match || null;
let minis = getMinis();
let mergeButtons = '';
let mainIsMatch = match && match.type === 'main';
mergeButtons += `<button class="kad-btn merge-main-btn" style="margin: 0 3px;${mainIsMatch ? ' outline: 2px solid #fff; font-weight: 800;' : ''}" title="Merge into Main">→ Main</button>`;
minis.forEach((mini, i) => {
let state = getTimerState(mini.time, new Date().getTime());
if (state.status !== "expired") {
mergeButtons += `<button class="kad-btn merge-mini-btn" data-index="${i}" style="margin: 0 3px;" title="Merge into Mini ${i+1}">→ Mini ${i+1}</button>`;
}
});
let matchNote = match
? `Identified: <strong>${match.label}</strong> —`
: `No feed identified — select manually: `;
let firstKad = drop.names[0] || '';
let lastKad = drop.count > 1 ? ` › ${drop.names[drop.count - 1]}` : '';
let kadSummary = `${drop.count} Kad${drop.count !== 1 ? 's' : ''} (${firstKad}${lastKad})`;
alert.html(`
🐱 <strong>New drop detected:</strong> ${kadSummary} — ${matchNote}
${mergeButtons}
<button id="discardDropBtn" class="kad-btn kad-btn-red" style="margin: 0 3px;">Discard</button>
`).show();
}
function pushMainToHistory() {
let currentMainTime = GM_getValue(LAST_REFRESH_TIME_KEY, 0);
if (currentMainTime <= 0) return;
let history = JSON.parse(GM_getValue(MAIN_HISTORY_KEY, "[]"));
let currentData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}"));
history.push({ time: currentMainTime, data: currentData });
if (history.length > 5) history.shift();
GM_setValue(MAIN_HISTORY_KEY, JSON.stringify(history));
}
function mergePendingDrop(type, miniIndex) {
let drop = {};
try { drop = JSON.parse(GM_getValue(PENDING_DROP_KEY, "{}")); } catch(e) {}
if (!drop.time) return;
if (type === 'main') {
pushMainToHistory();
let existing = {};
try { existing = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e) {}
let existingNamesSet = new Set(existing.names || []);
let mergedNames = [...(existing.names || [])];
let mergedFoods = [...(existing.foods || [])];
for (let i = 0; i < drop.names.length; i++) {
if (!existingNamesSet.has(drop.names[i])) {
mergedNames.push(drop.names[i]);
mergedFoods.push(drop.foods[i]);
}
}
let count = mergedNames.length;
GM_setValue(LAST_REFRESH_TIME_KEY, drop.time);
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify({
count, first: mergedNames[0], last: mergedNames[count - 1],
names: mergedNames, foods: mergedFoods
}));
} else {
let minis = getMinis();
let mini = minis[miniIndex];
if (!mini) return;
let existingNamesSet = new Set(mini.names || []);
let mergedNames = [...(mini.names || [])];
let mergedFoods = [...(mini.foods || [])];
for (let i = 0; i < drop.names.length; i++) {
if (!existingNamesSet.has(drop.names[i])) {
mergedNames.push(drop.names[i]);
mergedFoods.push(drop.foods[i]);
}
}
let count = mergedNames.length;
minis[miniIndex] = { ...mini, time: drop.time, count, first: mergedNames[0], last: mergedNames[count - 1], names: mergedNames, foods: mergedFoods };
saveMinis(minis);
}
GM_setValue(PENDING_DROP_KEY, "{}");
renderPendingDrop();
tickTimers();
}
function discardPendingDrop() {
GM_setValue(PENDING_DROP_KEY, "{}");
renderPendingDrop();
}
function demoteMainToMini() {
let mainTime = GM_getValue(LAST_REFRESH_TIME_KEY, 0);
if (!mainTime) return;
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e){}
addMini(mainTime, mainData.count || 0, mainData.first || "", mainData.last || "", mainData.names || [], mainData.foods || []);
let history = JSON.parse(GM_getValue(MAIN_HISTORY_KEY, "[]"));
if (history.length > 0) {
let previous = history.pop();
GM_setValue(MAIN_HISTORY_KEY, JSON.stringify(history));
GM_setValue(LAST_REFRESH_TIME_KEY, previous.time);
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify(previous.data));
} else {
GM_setValue(LAST_REFRESH_TIME_KEY, 0);
GM_setValue(MAIN_KAD_DATA_KEY, "{}");
}
tickTimers();
}
function promoteMiniToMain(index) {
let minis = getMinis();
if (!minis[index]) return;
pushMainToHistory();
let mini = minis[index];
minis.splice(index, 1);
saveMinis(minis);
GM_setValue(LAST_REFRESH_TIME_KEY, mini.time);
GM_setValue(MAIN_KAD_DATA_KEY, JSON.stringify({
count: mini.count,
first: mini.first,
last: mini.last,
names: mini.names || [],
foods: mini.foods || []
}));
tickTimers();
}
function getMinis() {
try {
let minis = JSON.parse(GM_getValue(MINI_REFRESH_TIMES_KEY, "[]"));
if (!Array.isArray(minis)) return [];
return minis.map(m => (typeof m === 'number') ? { time: m, count: 0, first: "", last: "", names: [], foods: [] } : m);
} catch(e) { return []; }
}
function saveMinis(minisArr) {
GM_setValue(MINI_REFRESH_TIMES_KEY, JSON.stringify(minisArr));
tickTimers();
}
function addMini(timeMs, count = 0, first = "", last = "", names = [], foods = []) {
let minis = getMinis();
minis.push({ time: timeMs, count, first, last, names, foods });
minis.sort((a, b) => a.time - b.time);
saveMinis(minis);
}
function removeMini(index) {
let minis = getMinis();
minis.splice(index, 1);
saveMinis(minis);
}
function tickTimers() {
let nowTime = new Date().getTime();
updateMainUI(nowTime);
renderMinis(nowTime);
renderPendingDrop();
}
function getTimerState(lastRefreshTime, currentTime) {
if (!lastRefreshTime || lastRefreshTime === 0 || (currentTime - lastRefreshTime > MAXIMUM_TIME_BETWEEN_REFRESHES_MS)) return { status: "expired" };
let mainWindowStart = lastRefreshTime + DURATION_UNTIL_START_OF_MAIN_WINDOW_MS;
let mainWindowEnd = mainWindowStart + DURATION_OF_MAIN_WINDOW_MS;
if (currentTime < mainWindowStart) return { status: "waiting", nextStart: mainWindowStart, countdown: mainWindowStart - currentTime };
if (currentTime >= mainWindowStart && currentTime < mainWindowEnd) return { status: "active", timeRemaining: mainWindowEnd - currentTime };
for (let i = 1; i <= 8; i++) {
let pStart = mainWindowStart + (i * 7 * 60000);
let pEnd = pStart + DURATION_OF_PENDING_WINDOWS_MS;
if (currentTime < pStart) return { status: "waiting", nextStart: pStart, countdown: pStart - currentTime };
if (currentTime >= pStart && currentTime < pEnd) return { status: "active", timeRemaining: pEnd - currentTime };
}
return { status: "expired" };
}
function renderTimerHTML(state) {
if (state.status === "expired") return '<span class="kad-text-red" style="font-size: 14px;">Missing or expired.</span>';
if (state.status === "waiting") return `<span class="kad-time-sub">Next:</span> <span>${formatWindowTime(new Date(state.nextStart))}</span> <span class="kad-divider">|</span> <span class="kad-time-sub">In:</span> <span class="kad-time-hl">${formatCountdown(new Date(state.countdown))}</span>`;
if (state.status === "active") return `<span class="kad-window-active">WINDOW ACTIVE!</span> <span class="kad-divider">|</span> <span class="kad-time-sub">Closes in:</span> <span class="kad-window-active">${Math.round(state.timeRemaining / 1000)}s</span>`;
return '<span style="font-size: 14px; color: #94a3b8;">Unknown state.</span>';
}
function updateMainUI(nowTime) {
let lastTime = GM_getValue(LAST_REFRESH_TIME_KEY, 0);
let state = getTimerState(lastTime, nowTime);
let container = $('#mainContainer');
if (state.status === "expired") {
$('#windowMissingAlert').show();
container.empty();
return;
}
$('#windowMissingAlert').hide();
let label = "Main";
try {
let mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}"));
label = formatKadLabel("Main", mainData.count, mainData.first, mainData.last);
} catch(e) {}
let lastRefreshStr = formatLastRefresh(lastTime);
container.html(`
<div class="kad-tracker-block kad-main-block">
<div class="kad-block-label" title="${label}">${label}</div>
<div class="kad-countdown-box">${renderTimerHTML(state)}</div>
<div class="kad-last-refresh">Last refresh: ${lastRefreshStr}</div>
<button class="kad-btn kad-btn-action demote-main-btn" title="Convert to Mini">↓ Demote</button>
<button class="kad-btn kad-btn-red kad-btn-remove remove-main-btn" title="Remove Main">✕</button>
</div>
`);
if (state.status === "waiting") checkAndTriggerNotification('main', state.countdown);
}
function renderMinis(nowTime) {
let minis = getMinis();
let container = $('#minisContainer');
container.empty();
minis.forEach((miniObj, index) => {
let state = getTimerState(miniObj.time, nowTime);
if (state.status === "expired") return;
let label = formatKadLabel(`Mini ${index + 1}`, miniObj.count, miniObj.first, miniObj.last);
let miniLastStr = formatLastRefresh(miniObj.time);
container.append(`
<div class="kad-tracker-block">
<div class="kad-block-label" title="${label}">${label}</div>
<div class="kad-countdown-box">${renderTimerHTML(state)}</div>
<div class="kad-last-refresh">Last refresh: ${miniLastStr}</div>
<button class="kad-btn kad-btn-action promote-mini-btn" data-index="${index}" title="Set as Main">↑ Promote</button>
<button class="kad-btn kad-btn-red kad-btn-remove remove-mini-btn" data-index="${index}" title="Remove Mini">✕</button>
</div>
`);
if (state.status === "waiting") checkAndTriggerNotification(`mini_${index}`, state.countdown);
});
}
function handleManualTime(type) {
let val = $('#manualTimeInput').val();
let newTime = parseTimeInput(val);
if(newTime) {
if (type === 'main') {
pushMainToHistory();
GM_setValue(LAST_REFRESH_TIME_KEY, newTime.getTime());
GM_setValue(MAIN_KAD_DATA_KEY, "{}");
} else {
addMini(newTime.getTime());
}
$('#manualTimeInput').val('');
tickTimers();
}
}
function showInputError(msg) {
$('#manualTimeInput').addClass('kad-input-error');
let hint = $('#timeInputHint');
hint.text(msg).show();
setTimeout(() => { $('#manualTimeInput').removeClass('kad-input-error'); hint.hide(); }, 2500);
}
function parseTimeInput(val) {
let inputtedTimes = val.split(":");
if (inputtedTimes.length < 2) { showInputError("Use HH:MM format."); return null; }
let hour = parseInt(inputtedTimes[0], 10);
let min = parseInt(inputtedTimes[1], 10);
let sec = inputtedTimes.length > 2 ? parseInt(inputtedTimes[2], 10) : 0;
if (isNaN(hour) || isNaN(min) || isNaN(sec) || hour < 0 || hour > 23 || min < 0 || min > 59 || sec < 0 || sec > 59) {
showInputError("Invalid time.");
return null;
}
let now = new Date();
let currentTime = now.getTime();
let nstWallStr = now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
let nstDate = new Date(nstWallStr);
let tzOffsetMs = currentTime - nstDate.getTime();
if (nstDate.getHours() <= 1 && hour >= 22) nstDate.setDate(nstDate.getDate() - 1);
nstDate.setHours(hour, min, sec, 0);
let absoluteNSTTime = nstDate.getTime() + tzOffsetMs;
if (absoluteNSTTime > currentTime || (currentTime - absoluteNSTTime) > MAXIMUM_TIME_BETWEEN_REFRESHES_MS) {
showInputError("Must be within 78 min.");
return null;
}
return new Date(absoluteNSTTime);
}
function generateBoardString(label, lastTimeMs, foodNames) {
if (!lastTimeMs || lastTimeMs === 0) return null;
let last = new Date(lastTimeMs);
let next = new Date(lastTimeMs + DURATION_UNTIL_START_OF_MAIN_WINDOW_MS);
let pends = [];
for(let i = 1; i <= 5; i++) {
let pend = new Date(next.getTime() + (i * 7 * 60000));
pends.push(":" + formatTwoDigits(pend.getMinutes()));
}
let lastStr = formatWindowTime(last);
let nextStr = formatWindowTime(next);
let foodLine = (foodNames && foodNames.length > 0)
? "\n" + foodNames.map(censorFood).reduce((acc, food, i) => acc + (i > 0 ? (i % 4 === 0 ? "\n\n" : "\n") : "") + food, "")
: '';
return `${label} @ ${lastStr}\nNext ${nextStr} / ${pends.join(" / ")}${foodLine}`;
}
function copyBoardTimes() {
let mainTime = GM_getValue(LAST_REFRESH_TIME_KEY, 0);
let minis = getMinis();
let now = new Date().getTime();
let postArray = [];
let mainState = getTimerState(mainTime, now);
if(mainState.status !== "expired") {
let mainData = {};
try { mainData = JSON.parse(GM_getValue(MAIN_KAD_DATA_KEY, "{}")); } catch(e){}
let label = formatKadLabel("Main", mainData.count, mainData.first, mainData.last);
postArray.push(generateBoardString(label, mainTime, mainData.foods || []));
}
minis.forEach((miniObj, index) => {
let state = getTimerState(miniObj.time, now);
if(state.status !== "expired") {
let label = formatKadLabel(`Mini ${index + 1}`, miniObj.count, miniObj.first, miniObj.last);
postArray.push(generateBoardString(label, miniObj.time, miniObj.foods || []));
}
});
if(postArray.length === 0) {
let btn = $('#copyBoardBtn');
btn.text("Nothing to copy");
setTimeout(() => btn.text("📋 Copy Post"), 1800);
return;
}
navigator.clipboard.writeText(postArray.join("\n\n")).then(() => {
let btn = $('#copyBoardBtn');
let oldText = btn.text();
btn.text("✅ Copied!");
btn.css("background", "#10b981");
setTimeout(() => { btn.text(oldText).css("background", ""); }, 1500);
});
}
function addClickToCopyFeature() {
$("#kadaotiesTable td:contains('is very sad')").each(function() {
let foodTag = $(this).find('strong').last();
if (foodTag.length && !foodTag.hasClass('kad-food-copy')) {
foodTag.addClass('kad-food-copy');
foodTag.attr('title', userIsLockedOut ? 'You are locked out!' : 'Click to copy to clipboard');
foodTag.on('click', function(e) {
e.preventDefault();
if (userIsLockedOut) {
let originalText = $(this).text();
$(this).text('Locked!');
$(this).css({ 'color': '#ef4444', 'border-color': '#ef4444' });
setTimeout(() => {
$(this).text(originalText);
$(this).css({ 'color': '', 'border-color': '' });
}, 1000);
return;
}
let foodName = $(this).text();
navigator.clipboard.writeText(foodName).then(() => {
$(this).text('Copied!');
$(this).css({ 'color': '#10b981', 'border-color': '#10b981' });
setTimeout(() => {
$(this).text(foodName);
$(this).css({ 'color': '', 'border-color': '' });
}, 800);
});
});
}
});
}
function setupNotificationSupport() {
let optedIn = GM_getValue(NOTIFICATION_OPT_IN_KEY);
if (optedIn) {
$('#notifyStatus').show();
} else if ("Notification" in window && typeof optedIn === "undefined") {
$('#enableNotifyBtn').show().on('click', function () {
Notification.requestPermission().then((permission) => {
GM_setValue(NOTIFICATION_OPT_IN_KEY, permission === "granted");
$('#enableNotifyBtn').hide();
if (permission === "granted") $('#notifyStatus').show();
})
});
}
}
function checkAndTriggerNotification(type, countdownRemainingMs) {
if (!GM_getValue(NOTIFICATION_OPT_IN_KEY)) return;
if (countdownRemainingMs <= 10000 && countdownRemainingMs > 9000) {
let nowMs = new Date().getTime();
if (nowMs - (notificationsTriggered[type] || 0) > 240000) {
notificationsTriggered[type] = nowMs;
let title = type === 'main' ? "Main Refresh Incoming!" : "Mini Refresh Incoming!";
showNotification(title, "Window starts in 10 seconds.");
}
}
}
function showNotification(title, body) {
let notification = new Notification(title, {
body: body,
icon: "https://itemdb.com.br/api/cache/preview/7f18f78e35daa6.png"
});
notification.onclick = () => {
notification.close();
window.focus();
}
}
function formatTwoDigits(n) { return n < 10 ? '0' + n : n; }
function formatCountdown(d) { return formatTwoDigits(d.getMinutes()) + ":" + formatTwoDigits(d.getSeconds()); }
function formatWindowTime(d) {
let nstStr = d.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
return nstStr.replace(/ /g, '').toLowerCase();
}
function formatLastRefresh(ms) {
if (!ms || ms === 0) return '';
let d = new Date(ms);
let nstStr = d.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
return nstStr.replace(/ /g, '').toLowerCase();
}