HeyMax SubCaps Viewer

Monitor network requests and display SubCaps calculations for UOB cards on HeyMax

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         HeyMax SubCaps Viewer
// @namespace    http://tampermonkey.net/
// @version      2.0.1
// @description  Monitor network requests and display SubCaps calculations for UOB cards on HeyMax
// @author       Laurence Putra Franslay (@laurenceputra)
// @source       https://github.com/laurenceputra/heymax-subcaps-viewer/
// @match        https://heymax.ai/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=heymax.ai
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-start
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log('[HeyMax SubCaps Viewer] Tampermonkey script starting...');

    // ============================================================================
    // DEBUG CONFIGURATION
    // ============================================================================
    
    const DEBUG_MODE = false; // Set to true for verbose logging
    
    // Logging utility functions
    function debugLog(...args) {
        if (DEBUG_MODE) {
            console.log(...args);
        }
    }
    
    function infoLog(message, color = '#4CAF50') {
        console.log(`%c[HeyMax SubCaps Viewer] ${message}`, `color: ${color}; font-weight: bold;`);
    }
    
    function errorLog(...args) {
        console.error('[HeyMax SubCaps Viewer]', ...args);
    }

    // ============================================================================
    // PART 1: API INTERCEPTION VIA DIRECT MONKEY PATCHING
    // ============================================================================
    
    // Store original functions
    const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    const originalFetch = targetWindow.fetch;
    const originalXHROpen = targetWindow.XMLHttpRequest.prototype.open;
    const originalXHRSend = targetWindow.XMLHttpRequest.prototype.send;

    // Check if URL should be logged
    function shouldLogUrl(url) {
        try {
            const urlObj = new URL(url, window.location.href);
            
            if (urlObj.hostname !== 'heymax.ai') {
                return false;
            }
            
            const pathname = urlObj.pathname;
            
            if (pathname.startsWith('/cards/your-cards/')) {
                return true;
            }
            
            if (pathname.startsWith('/api/spend_tracking/cards/') && 
                (pathname.includes('/summary') || pathname.includes('/transactions'))) {
                return true;
            }
            
            if (pathname === '/api/spend_tracking/card_tracker') {
                return true;
            }
            
            return false;
        } catch (error) {
            return false;
        }
    }

    // ============================================================================
    // PART 2: DATA STORAGE FUNCTIONS
    // ============================================================================

    // Compiled regex patterns (created once for reuse)
    const REGEX_CARD_ID_API = /\/api\/spend_tracking\/cards\/([a-f0-9]+)\//;
    const REGEX_CARD_ID_PAGE = /\/cards\/your-cards\/([a-f0-9]+)/;

    // Extract card ID from URL
    function extractCardId(url) {
        const match = url.match(REGEX_CARD_ID_API);
        if (match) {
            return match[1];
        }
        
        if (url.includes('/cards//')) {
            const pageMatch = window.location.pathname.match(REGEX_CARD_ID_PAGE);
            return pageMatch ? pageMatch[1] : null;
        }
        
        return null;
    }

    // Determine the data type from URL
    function getDataType(url) {
        if (url.includes('/transactions')) {
            return 'transactions';
        } else if (url.includes('/summary')) {
            return 'summary';
        } else if (url.includes('/card_tracker')) {
            return 'card_tracker';
        }
        return null;
    }

    // Store API data with request type tracking
    function storeApiData(requestType, method, url, status, data, timestamp) {
        const typeEmoji = requestType === 'fetch' ? '🌐' : '📡';
        const typeLabel = requestType === 'fetch' ? 'FETCH' : 'XHR';
        
        const dataType = getDataType(url);
        let cardId = extractCardId(url);
        
        // For card_tracker, try to get card ID from the current page URL
        if (dataType === 'card_tracker' && !cardId) {
            const pageMatch = window.location.pathname.match(REGEX_CARD_ID_PAGE);
            if (pageMatch) {
                cardId = pageMatch[1];
            }
        }
        
        // Get existing card data
        const cardDataStr = GM_getValue('cardData', '{}');
        const cardData = JSON.parse(cardDataStr);
        
        debugLog('[HeyMax SubCaps Viewer] Current cardData before update:', cardData);
        
        if (dataType && cardId) {
            // Initialize card object if it doesn't exist
            if (!cardData[cardId]) {
                cardData[cardId] = {};
            }
            
            // Store the latest data for this card ID and data type
            cardData[cardId][dataType] = {
                data: data,
                timestamp: timestamp,
                url: url,
                status: status,
                requestType: requestType
            };
            
            infoLog(`${typeEmoji} Stored ${dataType} for card ${cardId} via ${typeLabel}`, requestType === 'fetch' ? '#2196F3' : '#FF9800');
        } else if (dataType === 'card_tracker' && !cardId) {
            // card_tracker on main listing page (no specific card ID)
            cardData['card_tracker'] = {
                data: data,
                timestamp: timestamp,
                url: url,
                status: status,
                requestType: requestType
            };
            
            infoLog(`${typeEmoji} Stored card_tracker (global) via ${typeLabel}`, requestType === 'fetch' ? '#2196F3' : '#FF9800');
        }
        
        // Save the updated cardData structure
        GM_setValue('cardData', JSON.stringify(cardData));
        
        if (DEBUG_MODE) {
            console.groupCollapsed(`%c[HeyMax SubCaps Viewer] ${typeEmoji} Storage Details`, `color: ${requestType === 'fetch' ? '#2196F3' : '#FF9800'};`);
            console.log('Request Type:', typeLabel);
            console.log('Method:', method);
            console.log('URL:', url);
            console.log('Status:', status);
            console.log('Timestamp:', timestamp);
            console.log('Updated cardData:', cardData);
            console.groupEnd();
        }
    }

    // ============================================================================
    // PART 3: FETCH INTERCEPTION
    // ============================================================================

    // Reusable fetch interceptor factory
    function createFetchInterceptor() {
        return async function(...args) {
            const [resource, config] = args;
            const url = typeof resource === 'string' ? resource : resource.url;
            const method = config?.method || 'GET';

            debugLog(`%c[HeyMax SubCaps Viewer] 🌐 FETCH Intercepted: ${method} ${url}`, 'color: #2196F3; font-weight: bold;');

            const response = await originalFetch.apply(this, args);
            
            const shouldLog = shouldLogUrl(url);
            debugLog(`%c[HeyMax SubCaps Viewer] 🌐 FETCH Response: ${method} ${url} - Status: ${response.status} - Will Log: ${shouldLog}`, 
                shouldLog ? 'color: #4CAF50;' : 'color: #9E9E9E;');
            
            if (!shouldLog) {
                return response;
            }

            const clonedResponse = response.clone();
            
            try {
                const contentType = response.headers.get('content-type');
                let responseData;
                
                if (contentType && contentType.includes('application/json')) {
                    responseData = await clonedResponse.json();
                    const timestamp = new Date().toISOString();
                    
                    if (DEBUG_MODE) {
                        console.groupCollapsed(`%c[HeyMax SubCaps Viewer] 🌐 FETCH Response Logged`, 'color: #2196F3; font-weight: bold;');
                        console.log('Method:', method);
                        console.log('URL:', url);
                        console.log('Status:', response.status);
                        console.log('Response Data:', responseData);
                        console.groupEnd();
                    }
                    
                    storeApiData('fetch', method, url, response.status, responseData, timestamp);
                } else {
                    const text = await clonedResponse.text();
                    if (text.length < 1000) {
                        const timestamp = new Date().toISOString();
                        
                        if (DEBUG_MODE) {
                            console.groupCollapsed(`%c[HeyMax SubCaps Viewer] 🌐 FETCH Response Logged`, 'color: #2196F3; font-weight: bold;');
                            console.log('Method:', method);
                            console.log('URL:', url);
                            console.log('Status:', response.status);
                            console.log('Response Data:', text);
                            console.groupEnd();
                        }
                        
                        storeApiData('fetch', method, url, response.status, text, timestamp);
                    }
                }
            } catch (error) {
                errorLog('Error reading fetch response:', error);
            }

            return response;
        };
    }

    // Apply initial fetch interceptor
    targetWindow.fetch = createFetchInterceptor();
    targetWindow.fetch.patchedVersion = true;

    // ============================================================================
    // PART 4: XMLHttpRequest INTERCEPTION
    // ============================================================================

    // Reusable XHR interceptor factory - returns open and send methods
    function createXHRInterceptors() {
        const openInterceptor = function(method, url, ...rest) {
            this._method = method;
            this._url = url;
            debugLog(`%c[HeyMax SubCaps Viewer] 📡 XHR Intercepted: ${method} ${url}`, 'color: #FF9800; font-weight: bold;');
            return originalXHROpen.apply(this, [method, url, ...rest]);
        };

        const sendInterceptor = function(...args) {
            const url = this._url;
            const method = this._method;
            
            if (url && typeof url === 'string') {
                // Use named function for proper cleanup
                const loadHandler = function() {
                    if (this.readyState === 4 && this.status >= 200 && this.status < 300) {
                        const shouldLog = shouldLogUrl(url);
                        debugLog(`%c[HeyMax SubCaps Viewer] 📡 XHR Response: ${method} ${url} - Status: ${this.status} - Will Log: ${shouldLog}`, 
                            shouldLog ? 'color: #4CAF50;' : 'color: #9E9E9E;');
                        
                        if (!shouldLog) {
                            // Clean up listener before early return
                            this.removeEventListener('load', loadHandler);
                            return;
                        }

                        try {
                            const contentType = this.getResponseHeader('content-type');
                            let responseData;

                            if (contentType && contentType.includes('application/json')) {
                                responseData = JSON.parse(this.responseText);
                            } else if (this.responseText && this.responseText.length < 1000) {
                                responseData = this.responseText;
                            }

                            if (responseData) {
                                const timestamp = new Date().toISOString();
                                
                                if (DEBUG_MODE) {
                                    console.groupCollapsed(`%c[HeyMax SubCaps Viewer] 📡 XHR Response Logged`, 'color: #FF9800; font-weight: bold;');
                                    console.log('Method:', method);
                                    console.log('URL:', url);
                                    console.log('Status:', this.status);
                                    console.log('Response Data:', responseData);
                                    console.groupEnd();
                                }
                                
                                storeApiData('xhr', method, url, this.status, responseData, timestamp);
                            }
                        } catch (error) {
                            errorLog('Error processing XHR response:', error);
                        }
                    }
                    
                    // Always clean up listener after execution
                    this.removeEventListener('load', loadHandler);
                };
                
                this.addEventListener('load', loadHandler);
            }
            
            return originalXHRSend.apply(this, args);
        };

        return { openInterceptor, sendInterceptor };
    }

    // Apply initial XHR interceptors
    const { openInterceptor: initialXHROpen, sendInterceptor: initialXHRSend } = createXHRInterceptors();
    targetWindow.XMLHttpRequest.prototype.open = initialXHROpen;
    targetWindow.XMLHttpRequest.prototype.send = initialXHRSend;

    console.log('[HeyMax SubCaps Viewer] API interception initialized');

    // ============================================================================
    // PART 4.5: PATCH PROTECTION WITH EXPONENTIAL BACKOFF
    // ============================================================================

    let patchCheckInterval = 1000; // Start at 1 second
    const MIN_CHECK_INTERVAL = 1000; // Minimum 1 second
    const MAX_CHECK_INTERVAL = 60000; // Maximum 60 seconds
    const BACKOFF_MULTIPLIER = 1.5; // Increase by 50% each time
    let consecutiveStableChecks = 0;
    const STABLE_CHECKS_THRESHOLD = 10; // After 10 stable checks, interval increases

    // Store references to the current XHR interceptors for comparison
    let currentXHROpen = initialXHROpen;
    let currentXHRSend = initialXHRSend;

    function checkAndReapplyPatches() {
        let patchesOverwritten = false;

        // Check if fetch was overwritten (marker property missing or false)
        if (typeof targetWindow.fetch !== 'function' || !targetWindow.fetch.patchedVersion) {
            if (typeof originalFetch !== 'function') {
                infoLog('❌ Cannot re-apply fetch patch: originalFetch is not a function. Skipping patch to avoid breaking fetch.', '#F44336');
            } else {
                infoLog('⚠️ Fetch patch overwritten, re-applying...', '#FF9800');
                targetWindow.fetch = createFetchInterceptor();
                targetWindow.fetch.patchedVersion = true;
                patchesOverwritten = true;
            }
        }

        // Check if XHR was overwritten
        if (targetWindow.XMLHttpRequest.prototype.open !== currentXHROpen || 
            targetWindow.XMLHttpRequest.prototype.send !== currentXHRSend) {
            infoLog('⚠️ XHR patch overwritten, re-applying...', '#FF9800');
            
            const { openInterceptor, sendInterceptor } = createXHRInterceptors();
            Object.assign(targetWindow.XMLHttpRequest.prototype, {
                open: openInterceptor,
                send: sendInterceptor
            });
            
            // Update stored references
            currentXHROpen = openInterceptor;
            currentXHRSend = sendInterceptor;
            
            patchesOverwritten = true;
        }

        // Adjust check interval based on patch stability
        if (patchesOverwritten) {
            // Patches were overwritten, reset to minimum interval
            consecutiveStableChecks = 0;
            patchCheckInterval = MIN_CHECK_INTERVAL;
            debugLog(`[HeyMax SubCaps Viewer] Patch check interval reset to ${patchCheckInterval}ms`);
        } else {
            // Patches are stable
            consecutiveStableChecks++;
            
            // After enough stable checks, increase interval with exponential backoff
            if (consecutiveStableChecks >= STABLE_CHECKS_THRESHOLD) {
                const newInterval = Math.min(
                    Math.floor(patchCheckInterval * BACKOFF_MULTIPLIER),
                    MAX_CHECK_INTERVAL
                );
                
                if (newInterval !== patchCheckInterval) {
                    patchCheckInterval = newInterval;
                    infoLog(`Patches stable, increasing check interval to ${patchCheckInterval}ms`, '#2196F3');
                }
                
                consecutiveStableChecks -= STABLE_CHECKS_THRESHOLD; // Allow carry-over for faster progression to higher intervals
            }
        }

        // Schedule next check with current interval
        setTimeout(checkAndReapplyPatches, patchCheckInterval);
    }

    // Start patch monitoring with immediate initial check
    checkAndReapplyPatches();
    infoLog('🛡️ Patch protection initialized with exponential backoff', '#4CAF50');

    // ============================================================================
    // PART 5: UI COMPONENTS
    // ============================================================================

    // ============================================================================
    // PART 5.1: MCC AND BLACKLIST CONSTANTS (Module-level for reuse)
    // ============================================================================
    
    // MCC code Sets for O(1) lookup - created once and reused
    const MCC_PPV_SHOPPING = new Set([4816, 5262, 5306, 5309, 5310, 5311, 5331, 5399, 5611, 5621, 5631, 5641, 5651, 5661, 5691, 5699, 5732, 5733, 5734, 5735, 5912, 5942, 5944, 5945, 5946, 5947, 5948, 5949, 5964, 5965, 5966, 5967, 5968, 5969, 5970, 5992, 5999]);
    const MCC_PPV_DINING = new Set([5811, 5812, 5814, 5333, 5411, 5441, 5462, 5499, 8012, 9751]);
    const MCC_PPV_ENTERTAINMENT = new Set([7278, 7832, 7841, 7922, 7991, 7996, 7998, 7999]);
    const MCC_BLACKLIST = new Set([4829, 4900, 5199, 5960, 5965, 5993, 6012, 6050, 6051, 6211, 6300, 6513, 6529, 6530, 6534, 6540, 7349, 7511, 7523, 7995, 8062, 8211, 8220, 8241, 8244, 8249, 8299, 8398, 8661, 8651, 8699, 8999, 9211, 9222, 9223, 9311, 9402, 9405, 9399]);
    
    // Merchant name blacklist - created once and reused
    const BLACKLIST_MERCHANT_PREFIXES = [
        "AXS", "AMAZE", "AMAZE* TRANSIT", "BANC DE BINARY", "BANCDEBINARY.COM",
        "EZ LINK PTE LTD (FEVO)", "EZ Link transport", "EZ Link", "EZ-LINK (IMAGINE CARD)",
        "EZ-Link EZ-Reload (ATU)", "EZLINK", "EzLink", "EZ-LINK", "FlashPay ATU",
        "MB * MONEYBOOKERS.COM", "NETS VCASHCARD", "OANDA ASIA PAC", "OANDAASIAPA",
        "PAYPAL * BIZCONSULTA", "PAYPAL * CAPITALROYA", "PAYPAL * OANDAASIAPA",
        "Saxo Cap Mkts Pte Ltd", "SKR*SKRILL.COM", "SKR*xglobalmarkets.com", "SKYFX.COM",
        "TRANSIT", "WWW.IGMARKETS.COM.SG", "IPAYMY", "RWS-LEVY", "SMOOVE PAY",
        "SINGPOST-SAM", "RazerPay", "NORWDS"
    ];

    // Helper functions at module level (created once)
    const roundDownToNearestFive = (amount) => Math.floor(amount / 5) * 5;
    
    const getBlacklistReason = (transaction) => {
        const mccCode = parseInt(transaction.mcc_code, 10);
        if (MCC_BLACKLIST.has(mccCode)) {
            return `Blacklisted MCC ${mccCode}`;
        }
        
        if (transaction.merchant_name) {
            for (const prefix of BLACKLIST_MERCHANT_PREFIXES) {
                if (transaction.merchant_name.startsWith(prefix)) {
                    return `Blacklisted merchant prefix: ${prefix}`;
                }
            }
        }
        
        return null;
    };
    
    const isEligibleForPPVOnline = (mccCode) => {
        return MCC_PPV_SHOPPING.has(mccCode) || MCC_PPV_DINING.has(mccCode) || MCC_PPV_ENTERTAINMENT.has(mccCode);
    };

    // Extract card ID from URL
    function extractCardIdFromUrl() {
        const match = window.location.pathname.match(/\/cards\/your-cards\/([a-f0-9]+)/);
        return match ? match[1] : null;
    }

    // Calculate buckets from transaction data
    function calculateBuckets(apiResponse, cardShortName = 'UOB PPV', includeDetails = false) {
        const transactionDetails = includeDetails ? {
            included: {
                contactless: [],
                online: [],
                foreignCurrency: []
            },
            excluded: {
                blacklisted: [],
                notEligible: [],
                wrongPaymentMethod: []
            }
        } : null;

        if (cardShortName === 'UOB VS') {
            return calculateVSBuckets(apiResponse, getBlacklistReason, transactionDetails, includeDetails);
        } else {
            return calculatePPVBuckets(apiResponse, getBlacklistReason, roundDownToNearestFive, isEligibleForPPVOnline, transactionDetails, includeDetails);
        }
    }

    // Calculate UOB VS buckets
    function calculateVSBuckets(apiResponse, getBlacklistReason, transactionDetails, includeDetails) {
        let contactlessBucket = 0;
        let foreignCurrencyBucket = 0;

        apiResponse.forEach((transactionObj) => {
            const transaction = transactionObj.transaction;
            
            const blacklistReason = getBlacklistReason(transaction);
            if (blacklistReason) {
                if (includeDetails) {
                    transactionDetails.excluded.blacklisted.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        reason: blacklistReason,
                        mcc: transaction.mcc_code,
                        date: transaction.transaction_date || transaction.date
                    });
                }
                return;
            }

            if (transaction.original_currency && transaction.original_currency !== 'SGD') {
                foreignCurrencyBucket += transaction.base_currency_amount;
                if (includeDetails) {
                    transactionDetails.included.foreignCurrency.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        currency: transaction.original_currency,
                        date: transaction.transaction_date || transaction.date
                    });
                }
            } else if (transaction.payment_tag === 'contactless') {
                contactlessBucket += transaction.base_currency_amount;
                if (includeDetails) {
                    transactionDetails.included.contactless.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        date: transaction.transaction_date || transaction.date
                    });
                }
            } else {
                if (includeDetails) {
                    transactionDetails.excluded.wrongPaymentMethod.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        paymentMethod: transaction.payment_tag || 'unknown',
                        date: transaction.transaction_date || transaction.date
                    });
                }
            }
        });

        const result = { contactless: contactlessBucket, foreignCurrency: foreignCurrencyBucket };
        if (includeDetails) {
            result.details = transactionDetails;
        }
        return result;
    }

    // Calculate UOB PPV buckets
    function calculatePPVBuckets(apiResponse, getBlacklistReason, roundDownToNearestFive, isEligibleForPPVOnline, transactionDetails, includeDetails) {
        let contactlessBucket = 0;
        let onlineBucket = 0;

        apiResponse.forEach((transactionObj) => {
            const transaction = transactionObj.transaction;
            
            const blacklistReason = getBlacklistReason(transaction);
            if (blacklistReason) {
                if (includeDetails) {
                    transactionDetails.excluded.blacklisted.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        reason: blacklistReason,
                        mcc: transaction.mcc_code,
                        date: transaction.transaction_date || transaction.date
                    });
                }
                return;
            }

            if (transaction.payment_tag === 'contactless') {
                const roundedAmount = roundDownToNearestFive(transaction.base_currency_amount);
                contactlessBucket += roundedAmount;
                if (includeDetails) {
                    transactionDetails.included.contactless.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        roundedAmount: roundedAmount,
                        date: transaction.transaction_date || transaction.date
                    });
                }
            } else if (transaction.payment_tag === 'online') {
                const mccCode = parseInt(transaction.mcc_code, 10);
                if (isEligibleForPPVOnline(mccCode)) {
                    const roundedAmount = roundDownToNearestFive(transaction.base_currency_amount);
                    onlineBucket += roundedAmount;
                    if (includeDetails) {
                        transactionDetails.included.online.push({
                            merchant: transaction.merchant_name || 'Unknown',
                            amount: transaction.base_currency_amount,
                            roundedAmount: roundedAmount,
                            mcc: mccCode,
                            date: transaction.transaction_date || transaction.date
                        });
                    }
                } else {
                    if (includeDetails) {
                        transactionDetails.excluded.notEligible.push({
                            merchant: transaction.merchant_name || 'Unknown',
                            amount: transaction.base_currency_amount,
                            mcc: mccCode,
                            reason: 'MCC not in eligible categories',
                            date: transaction.transaction_date || transaction.date
                        });
                    }
                }
            } else {
                if (includeDetails) {
                    transactionDetails.excluded.wrongPaymentMethod.push({
                        merchant: transaction.merchant_name || 'Unknown',
                        amount: transaction.base_currency_amount,
                        paymentMethod: transaction.payment_tag || 'unknown',
                        date: transaction.transaction_date || transaction.date
                    });
                }
            }
        });

        const result = { contactless: contactlessBucket, online: onlineBucket };
        if (includeDetails) {
            result.details = transactionDetails;
        }
        return result;
    }

    // Check if button should be visible
    function shouldShowButton(cardId) {
        const cardDataStr = GM_getValue('cardData', '{}');
        const cardData = JSON.parse(cardDataStr);

        debugLog('[HeyMax SubCaps Viewer] Checking visibility - cardData:', cardData);
        debugLog('[HeyMax SubCaps Viewer] Checking visibility - cardId:', cardId);

        if (!cardData || !cardId) {
            debugLog('[HeyMax SubCaps Viewer] No cardData or cardId, hiding button');
            return false;
        }

        const cardInfo = cardData[cardId];
        debugLog('[HeyMax SubCaps Viewer] Card info exists:', !!cardInfo);

        if (!cardInfo || !cardInfo.card_tracker) {
            debugLog('[HeyMax SubCaps Viewer] No card info or card_tracker, hiding button');
            return false;
        }

        const cardTrackerData = cardInfo.card_tracker.data;
        debugLog('[HeyMax SubCaps Viewer] Card tracker data exists:', !!cardTrackerData);

        if (!cardTrackerData || !cardTrackerData.card) {
            debugLog('[HeyMax SubCaps Viewer] No card tracker data or card object, hiding button');
            return false;
        }

        const shortName = cardTrackerData.card.short_name;
        debugLog('[HeyMax SubCaps Viewer] Card short_name:', shortName);
        const isSupportedCard = shortName === 'UOB PPV' || shortName === 'UOB VS';
        debugLog('[HeyMax SubCaps Viewer] Is supported card:', isSupportedCard);
        return isSupportedCard;
    }

    // Create button styles object
    const BUTTON_STYLES = {
        base: `
            position: fixed;
            bottom: 20px;
            right: 20px;
            padding: 12px 24px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            z-index: 10000;
            transition: all 0.3s ease;
            display: none;
        `,
        hover: {
            backgroundColor: '#45a049',
            transform: 'scale(1.05)'
        },
        normal: {
            backgroundColor: '#4CAF50',
            transform: 'scale(1)'
        }
    };

    // Create the SubCaps button
    function createButton() {
        const button = document.createElement('button');
        button.id = 'heymax-subcaps-button';
        button.textContent = 'Subcaps';
        button.style.cssText = BUTTON_STYLES.base;

        button.addEventListener('mouseenter', () => {
            Object.assign(button.style, BUTTON_STYLES.hover);
        });

        button.addEventListener('mouseleave', () => {
            Object.assign(button.style, BUTTON_STYLES.normal);
        });

        button.addEventListener('click', showOverlay);

        return button;
    }

    // Create the overlay
    function createOverlay() {
        const overlay = document.createElement('div');
        overlay.id = 'heymax-subcaps-overlay';
        overlay.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.7);
            z-index: 10001;
            display: none;
            justify-content: center;
            align-items: center;
        `;

        const content = document.createElement('div');
        content.style.cssText = `
            background-color: white;
            padding: 30px;
            border-radius: 12px;
            max-width: 600px;
            width: 90%;
            max-height: 80vh;
            overflow-y: auto;
            position: relative;
            box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
        `;

        const closeButton = document.createElement('button');
        closeButton.textContent = '×';
        closeButton.style.cssText = `
            position: absolute;
            top: 10px;
            right: 15px;
            background: none;
            border: none;
            font-size: 32px;
            font-weight: bold;
            cursor: pointer;
            color: #666;
            line-height: 1;
            padding: 0;
            width: 32px;
            height: 32px;
            transition: color 0.3s ease;
        `;

        closeButton.addEventListener('mouseenter', function() {
            closeButton.style.color = '#000';
        });

        closeButton.addEventListener('mouseleave', function() {
            closeButton.style.color = '#666';
        });

        closeButton.addEventListener('click', function() {
            hideOverlay();
        });

        const title = document.createElement('h2');
        title.id = 'heymax-subcaps-title';
        title.textContent = 'Subcaps Analysis';
        title.style.cssText = `
            margin-top: 0;
            margin-bottom: 20px;
            color: #333;
            font-size: 24px;
        `;

        const resultsDiv = document.createElement('div');
        resultsDiv.id = 'heymax-subcaps-results';

        content.appendChild(closeButton);
        content.appendChild(title);
        content.appendChild(resultsDiv);
        overlay.appendChild(content);

        overlay.addEventListener('click', function(e) {
            if (e.target === overlay) {
                hideOverlay();
            }
        });

        return overlay;
    }

    // Show overlay with calculated data
    function showOverlay() {
        const overlay = document.getElementById('heymax-subcaps-overlay');
        const resultsDiv = document.getElementById('heymax-subcaps-results');
        const titleElement = document.getElementById('heymax-subcaps-title');

        if (!overlay || !resultsDiv) {
            errorLog('Overlay elements not found');
            return;
        }

        resultsDiv.innerHTML = '<p style="text-align: center; color: #666;">Loading data...</p>';
        overlay.style.display = 'flex';

        const cardId = extractCardIdFromUrl();
        const cardDataStr = GM_getValue('cardData', '{}');
        const cardData = JSON.parse(cardDataStr);

        debugLog('[HeyMax SubCaps Viewer] showOverlay - cardData:', cardData);
        debugLog('[HeyMax SubCaps Viewer] showOverlay - cardId:', cardId);

        if (!cardData || !cardId || !cardData[cardId]) {
            resultsDiv.innerHTML = '<p style="color: #f44336;">Error: No card data found</p>';
            return;
        }

        const transactionsData = cardData[cardId].transactions;
        if (!transactionsData || !transactionsData.data) {
            resultsDiv.innerHTML = '<p style="color: #f44336;">Error: No transaction data available</p>';
            return;
        }

        const cardTrackerData = cardData[cardId].card_tracker;
        const cardShortName = cardTrackerData && cardTrackerData.data && cardTrackerData.data.card
            ? cardTrackerData.data.card.short_name
            : 'UOB PPV';

        if (titleElement) {
            titleElement.textContent = `${cardShortName} Subcaps Analysis`;
        }

        try {
            const transactions = transactionsData.data;
            const results = calculateBuckets(transactions, cardShortName, true); // Request details

            displayResults(results, transactions.length, cardShortName);
        } catch (error) {
            errorLog('Error calculating data:', error);
            resultsDiv.innerHTML = '<p style="color: #f44336;">Error calculating data: ' + error.message + '</p>';
        }
    }

    // Hide overlay
    function hideOverlay() {
        const overlay = document.getElementById('heymax-subcaps-overlay');
        if (overlay) {
            overlay.style.display = 'none';
        }
    }

    // Helper function to determine color based on value and card type
    function getBucketColor(value, cardType) {
        if (cardType === 'UOB VS') {
            if (value < 1000) return '#FFC107'; // Yellow
            if (value <= 1200) return '#4CAF50'; // Green
            return '#f44336'; // Red
        }
        // UOB PPV
        return value < 600 ? '#4CAF50' : '#f44336';
    }

    // Helper function to get bucket limit
    function getBucketLimit(cardType) {
        return cardType === 'UOB VS' ? 1200 : 600;
    }

    // Display calculation results
    function displayResults(results, transactionCount, cardShortName = 'UOB PPV') {
        const resultsDiv = document.getElementById('heymax-subcaps-results');
        if (!resultsDiv) return;

        const contactlessColor = getBucketColor(results.contactless, cardShortName);
        const contactlessLimit = getBucketLimit(cardShortName);

        let html = `
            <div style="margin-bottom: 20px;">
                <p style="color: #666; font-size: 14px; margin-bottom: 15px;">
                    Analyzed ${transactionCount} transaction${transactionCount !== 1 ? 's' : ''}
                </p>
            </div>

            <div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
                <h3 style="margin-top: 0; color: #333; font-size: 18px;">Contactless Bucket</h3>
                <p style="font-size: 32px; font-weight: bold; margin: 10px 0;">
                    <span style="color: ${contactlessColor};">$${results.contactless.toFixed(2)}</span>
                    <span style="color: #333;"> / $${contactlessLimit}</span>
                </p>
                <div style="width: 100%; height: 12px; background-color: #e0e0e0; border-radius: 6px; overflow: hidden; margin: 15px 0;">
                    <div style="
                        width: ${Math.min((results.contactless / parseFloat(contactlessLimit)) * 100, 100)}%;
                        height: 100%;
                        background-color: ${contactlessColor};
                        transition: width 0.3s ease;
                    "></div>
                </div>
                <p style="color: #666; font-size: 14px; margin-bottom: 0;">
                    Total from contactless payments${cardShortName === 'UOB PPV' ? ' (rounded down to nearest $5)' : ''}
                </p>
                ${cardShortName === 'UOB VS' && results.contactless < 1000 ? `
                <p style="color: #F57C00; font-size: 14px; margin-top: 10px; margin-bottom: 0; font-weight: 500;">
                    To start earning bonus miles, you must spend at least $1,000 in this category.
                </p>
                ` : ''}
            </div>
        `;

        if (cardShortName === 'UOB VS') {
            const foreignCurrencyColor = getBucketColor(results.foreignCurrency, cardShortName);
            html += `
                <div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px;">
                    <h3 style="margin-top: 0; color: #333; font-size: 18px;">Foreign Currency Bucket</h3>
                    <p style="font-size: 32px; font-weight: bold; margin: 10px 0;">
                        <span style="color: ${foreignCurrencyColor};">$${results.foreignCurrency.toFixed(2)}</span>
                        <span style="color: #333;"> / $1200</span>
                    </p>
                    <div style="width: 100%; height: 12px; background-color: #e0e0e0; border-radius: 6px; overflow: hidden; margin: 15px 0;">
                        <div style="
                            width: ${Math.min((results.foreignCurrency / 1200) * 100, 100)}%;
                            height: 100%;
                            background-color: ${foreignCurrencyColor};
                            transition: width 0.3s ease;
                        "></div>
                    </div>
                    <p style="color: #666; font-size: 14px; margin-bottom: 0;">
                        Total from non-SGD transactions
                    </p>
                    ${results.foreignCurrency < 1000 ? `
                    <p style="color: #F57C00; font-size: 14px; margin-top: 10px; margin-bottom: 0; font-weight: 500;">
                        To start earning bonus miles, you must spend at least $1,000 in this category.
                    </p>
                    ` : ''}
                </div>
            `;
        } else {
            const onlineColor = getBucketColor(results.online, cardShortName);
            html += `
                <div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px;">
                    <h3 style="margin-top: 0; color: #333; font-size: 18px;">Online Bucket</h3>
                    <p style="font-size: 32px; font-weight: bold; margin: 10px 0;">
                        <span style="color: ${onlineColor};">$${results.online.toFixed(2)}</span>
                        <span style="color: #333;"> / $600</span>
                    </p>
                    <div style="width: 100%; height: 12px; background-color: #e0e0e0; border-radius: 6px; overflow: hidden; margin: 15px 0;">
                        <div style="
                            width: ${Math.min((results.online / 600) * 100, 100)}%;
                            height: 100%;
                            background-color: ${onlineColor};
                            transition: width 0.3s ease;
                        "></div>
                    </div>
                    <p style="color: #666; font-size: 14px; margin-bottom: 0;">
                        Total from eligible online transactions (rounded down to nearest $5)
                    </p>
                </div>
            `;
        }

        // Add transaction details section if available
        if (results.details) {
            html += `
                <div style="margin-top: 20px; border-top: 2px solid #e0e0e0; padding-top: 20px;">
                    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
                        <h3 style="margin: 0; color: #333; font-size: 16px;">Transaction Details</h3>
                        <button id="toggle-details-btn" style="
                            padding: 6px 16px;
                            background-color: #2196F3;
                            color: white;
                            border: none;
                            border-radius: 4px;
                            font-size: 13px;
                            cursor: pointer;
                        ">Show Details</button>
                    </div>
                    <div id="transaction-details-content" style="display: none; margin-top: 15px;">
                        ${generateTransactionDetailsHTML(results.details, cardShortName)}
                    </div>
                </div>
            `;
        }

        html += `
            <div style="margin-top: 20px; padding: 15px; background-color: #e3f2fd; border-radius: 8px;">
                <p style="margin: 0; font-size: 14px; color: #1976D2;">
                    <strong>Note:</strong> These calculations are based on the transaction data that has been loaded so far.
                </p>
            </div>
        `;

        resultsDiv.innerHTML = html;
        
        // Add event listener for toggle details button
        const toggleBtn = document.getElementById('toggle-details-btn');
        const detailsContent = document.getElementById('transaction-details-content');
        if (toggleBtn && detailsContent) {
            toggleBtn.addEventListener('click', function() {
                if (detailsContent.style.display === 'none') {
                    detailsContent.style.display = 'block';
                    toggleBtn.textContent = 'Hide Details';
                    toggleBtn.style.backgroundColor = '#F57C00';
                } else {
                    detailsContent.style.display = 'none';
                    toggleBtn.textContent = 'Show Details';
                    toggleBtn.style.backgroundColor = '#2196F3';
                }
            });
        }
    }

    // Generate HTML for transaction details
    function generateTransactionDetailsHTML(details, cardShortName) {
        // Helper function to generate table header cell
        const headerCell = (text, align = 'left') => 
            `<th style="padding: 8px; text-align: ${align}; border-bottom: 1px solid #ddd;">${text}</th>`;
        
        // Helper function to generate table data cell
        const dataCell = (text, align = 'left', fontSize = null) => {
            const fontSizeStyle = fontSize ? `font-size: ${fontSize}; ` : '';
            return `<td style="padding: 6px 8px; text-align: ${align}; ${fontSizeStyle}border-bottom: 1px solid #eee;">${text}</td>`;
        };
        
        // Helper function to format currency
        const formatCurrency = (amount) => `$${amount.toFixed(2)}`;
        
        // Helper function to check if rounded column should be shown
        const showRoundedColumn = cardShortName === 'UOB PPV';
        
        // Helper function to generate included transaction row
        const generateIncludedRow = (txn) => {
            let row = `<tr>`;
            row += dataCell(txn.merchant);
            row += dataCell(formatCurrency(txn.amount), 'right');
            if (showRoundedColumn && txn.roundedAmount !== undefined) {
                row += dataCell(formatCurrency(txn.roundedAmount), 'right');
            }
            row += `</tr>`;
            return row;
        };
        
        // Helper function to generate table wrapper
        const tableWrapper = (headers, rows) => `
            <div style="max-height: 200px; overflow-y: auto; margin-top: 5px; border: 1px solid #ddd; border-radius: 4px;">
                <table style="width: 100%; font-size: 12px; border-collapse: collapse;">
                    <thead>
                        <tr style="background-color: #f5f5f5;">
                            ${headers}
                        </tr>
                    </thead>
                    <tbody>
                        ${rows}
                    </tbody>
                </table>
            </div>
        `;
        
        let html = '';
        
        // Included transactions
        const includedSections = cardShortName === 'UOB VS' 
            ? [
                { key: 'contactless', title: 'Contactless Transactions', count: details.included.contactless.length },
                { key: 'foreignCurrency', title: 'Foreign Currency Transactions', count: details.included.foreignCurrency.length }
              ]
            : [
                { key: 'contactless', title: 'Contactless Transactions', count: details.included.contactless.length },
                { key: 'online', title: 'Online Transactions', count: details.included.online.length }
              ];
        
        html += '<h4 style="color: #4CAF50; margin-top: 0;">Included Transactions</h4>';
        
        includedSections.forEach(section => {
            if (section.count > 0) {
                // Build headers
                let headers = headerCell('Merchant') + headerCell('Amount', 'right');
                if (showRoundedColumn) {
                    headers += headerCell('Rounded', 'right');
                }
                
                // Build rows
                const rows = details.included[section.key].map(generateIncludedRow).join('');
                
                html += `
                    <div style="margin-bottom: 15px;">
                        <strong>${section.title} (${section.count})</strong>
                        ${tableWrapper(headers, rows)}
                    </div>
                `;
            }
        });
        
        // Excluded transactions
        const excludedCount = details.excluded.blacklisted.length + 
                             details.excluded.notEligible.length + 
                             details.excluded.wrongPaymentMethod.length;
        
        if (excludedCount > 0) {
            html += '<h4 style="color: #f44336; margin-top: 20px;">Excluded Transactions</h4>';
            
            // Blacklisted transactions
            if (details.excluded.blacklisted.length > 0) {
                const headers = headerCell('Merchant') + headerCell('Amount', 'right') + headerCell('Reason');
                const rows = details.excluded.blacklisted.map(txn => {
                    return `<tr>` +
                        dataCell(txn.merchant) +
                        dataCell(formatCurrency(txn.amount), 'right') +
                        dataCell(txn.reason, 'left', '11px') +
                        `</tr>`;
                }).join('');
                
                html += `
                    <div style="margin-bottom: 15px;">
                        <strong>Blacklisted (${details.excluded.blacklisted.length})</strong>
                        ${tableWrapper(headers, rows)}
                    </div>
                `;
            }
            
            // Not eligible MCC transactions
            if (details.excluded.notEligible.length > 0) {
                const headers = headerCell('Merchant') + headerCell('Amount', 'right') + headerCell('MCC', 'center');
                const rows = details.excluded.notEligible.map(txn => {
                    return `<tr>` +
                        dataCell(txn.merchant) +
                        dataCell(formatCurrency(txn.amount), 'right') +
                        dataCell(txn.mcc, 'center', '11px') +
                        `</tr>`;
                }).join('');
                
                html += `
                    <div style="margin-bottom: 15px;">
                        <strong>Not Eligible MCC (${details.excluded.notEligible.length})</strong>
                        ${tableWrapper(headers, rows)}
                    </div>
                `;
            }
            
            // Wrong payment method transactions
            if (details.excluded.wrongPaymentMethod.length > 0) {
                const headers = headerCell('Merchant') + headerCell('Amount', 'right') + headerCell('Method', 'center');
                const rows = details.excluded.wrongPaymentMethod.map(txn => {
                    return `<tr>` +
                        dataCell(txn.merchant) +
                        dataCell(formatCurrency(txn.amount), 'right') +
                        dataCell(txn.paymentMethod, 'center', '11px') +
                        `</tr>`;
                }).join('');
                
                html += `
                    <div style="margin-bottom: 15px;">
                        <strong>Wrong Payment Method (${details.excluded.wrongPaymentMethod.length})</strong>
                        ${tableWrapper(headers, rows)}
                    </div>
                `;
            }
        }
        
        return html;
    }

    // Update button visibility
    function updateButtonVisibility() {
        const button = document.getElementById('heymax-subcaps-button');
        if (!button) return;

        const cardId = extractCardIdFromUrl();
        debugLog('[HeyMax SubCaps Viewer] Extracted card ID:', cardId);

        if (cardId) {
            const shouldShow = shouldShowButton(cardId);
            debugLog(`[HeyMax SubCaps Viewer] Button visibility for card ${cardId}: ${shouldShow}`);
            button.style.display = shouldShow ? 'block' : 'none';
        } else {
            button.style.display = 'none';
            debugLog('[HeyMax SubCaps Viewer] No card ID in URL, button hidden');
        }
    }

    // Initialize UI
    function initializeUI() {
        if (!document.body) {
            debugLog('[HeyMax SubCaps Viewer] document.body not ready, waiting...');
            setTimeout(initializeUI, 100);
            return;
        }

        infoLog('Initializing UI components...');

        const button = createButton();
        document.body.appendChild(button);
        debugLog('[HeyMax SubCaps Viewer] Button element created and appended');

        const overlay = createOverlay();
        document.body.appendChild(overlay);
        debugLog('[HeyMax SubCaps Viewer] Overlay element created and appended');

        updateButtonVisibility();

        let lastUrl = window.location.href;
        let debounceTimer = null;
        const observer = new MutationObserver(function(mutations) {
            const hasSignificantChange = mutations.some(mutation =>
                mutation.type === 'childList' && mutation.addedNodes.length > 0
            );

            if (!hasSignificantChange) {
                return;
            }

            if (debounceTimer) {
                clearTimeout(debounceTimer);
            }

            debounceTimer = setTimeout(function() {
                if (window.location.href !== lastUrl) {
                    lastUrl = window.location.href;
                    updateButtonVisibility();
                }
            }, 250);
        });

        observer.observe(document.body, { childList: true, subtree: true });

        // Listen for storage changes instead of polling
        window.addEventListener('storage', updateButtonVisibility);
        
        // Check visibility on page visibility changes (more efficient than polling)
        document.addEventListener('visibilitychange', function() {
            if (!document.hidden) {
                updateButtonVisibility();
            }
        });
    }

    // Wait for DOM to be ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeUI);
    } else {
        initializeUI();
    }

    console.log('[HeyMax SubCaps Viewer] Tampermonkey script initialized');
})();