KadWatch

Kadoatery refresh tracker — Main/Mini timers, board copy, lock detection, food logging.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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> &bull; <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> &mdash;`
        : `No feed identified &mdash; 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} &mdash; ${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();
}