Google Finance Statistics

Display comprehensive portfolio statistics on Google Finance

// ==UserScript==
// @name         Google Finance Statistics
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Display comprehensive portfolio statistics on Google Finance
// @author       MakMak
// @match        https://www.google.com/finance/*
// @icon         https://www.gstatic.com/finance/favicon/favicon.png
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const realizedGainSelector = "div.H1MkHc > span.P2Luy.Ez2Ioe";
    const unrealizedGainSelector = "div.hrdhqb";
    const portfolioValueSelector = "div.YMlKec.fxKbKc";
    const activityTabSelector = "div[data-tab-id='activity']";
    const notificationId = 'gain-calculator-userscript-result';

    let currentUrl = window.location.href;
    let calculationTimeout;
    let isActivityTabSelected = false;

    // Drag & Drop state
    let isDragging = false;
    let dragOffset = { x: 0, y: 0 };
    let currentNotification = null;

    // --- Helper Functions ---
    function isPortfolioPage() {
        const url = window.location.href;
        return url.includes('/finance/portfolio/');
    }

    function parseCurrency(text) {
        if (typeof text !== 'string' || !text) return NaN;
        // Remove currency symbols, thousand separators, and whitespace, then parse
        const cleanText = text.trim().replace(/[€$,]/g, '');
        return parseFloat(cleanText);
    }

    function getLastElement(selector) {
        const elements = document.querySelectorAll(selector);
        if (elements.length === 0) return null;

        // Filter out grayed/disabled elements by checking opacity or visibility
        const activeElements = Array.from(elements).filter(el => {
            const style = window.getComputedStyle(el);
            return style.opacity !== '0' &&
                   style.visibility !== 'hidden' &&
                   style.display !== 'none' &&
                   !el.closest('[style*="opacity: 0"]') &&
                   !el.closest('[style*="visibility: hidden"]');
        });

        return activeElements.length > 0 ? activeElements[activeElements.length - 1] : null;
    }

    function checkActivityTab() {
        const activityTabs = document.querySelectorAll(activityTabSelector);
        if (activityTabs.length === 0) return false;

        const lastActivityTab = activityTabs[activityTabs.length - 1];
        return lastActivityTab.getAttribute('aria-selected') === 'true';
    }

    function handleActivityTabChange() {
        // Skip if not on portfolio page
        if (!isPortfolioPage()) return;

        const currentActivityTabState = checkActivityTab();

        if (currentActivityTabState !== isActivityTabSelected) {
            isActivityTabSelected = currentActivityTabState;

            if (isActivityTabSelected) {
                console.log('Activity tab selected, refreshing Realized Gain statistics...');
                // Wait a bit for the activity data to load, then recalculate
                setTimeout(() => {
                    calculateStatistics();
                }, 800);
            }
        }
    }

    function waitForElements(selectors, maxWait = 5000) {
        return new Promise((resolve) => {
            const startTime = Date.now();

            function check() {
                const found = selectors.every(selector => getLastElement(selector) !== null);

                if (found || Date.now() - startTime > maxWait) {
                    resolve(found);
                } else {
                    setTimeout(check, 100);
                }
            }

            check();
        });
    }

    // --- Drag & Drop Functions ---
    function getEventCoords(e) {
        // Handle both mouse and touch events
        if (e.touches && e.touches.length > 0) {
            return { x: e.touches[0].clientX, y: e.touches[0].clientY };
        } else if (e.changedTouches && e.changedTouches.length > 0) {
            return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
        } else {
            return { x: e.clientX, y: e.clientY };
        }
    }

    function startDrag(e) {
        if (!currentNotification) return;

        isDragging = true;
        const coords = getEventCoords(e);
        const rect = currentNotification.getBoundingClientRect();

        dragOffset.x = coords.x - rect.left;
        dragOffset.y = coords.y - rect.top;

        // Add dragging class for visual feedback
        currentNotification.classList.add('dragging');

        // Prevent default to avoid text selection on desktop
        e.preventDefault();
    }

    function drag(e) {
        if (!isDragging || !currentNotification) return;

        e.preventDefault();
        const coords = getEventCoords(e);

        let newX = coords.x - dragOffset.x;
        let newY = coords.y - dragOffset.y;

        // Keep within viewport bounds
        const rect = currentNotification.getBoundingClientRect();
        const maxX = window.innerWidth - rect.width;
        const maxY = window.innerHeight - rect.height;

        newX = Math.max(0, Math.min(newX, maxX));
        newY = Math.max(0, Math.min(newY, maxY));

        currentNotification.style.left = newX + 'px';
        currentNotification.style.top = newY + 'px';
        currentNotification.style.right = 'auto'; // Override right positioning
    }

    function stopDrag() {
        if (!isDragging || !currentNotification) return;

        isDragging = false;
        currentNotification.classList.remove('dragging');
    }

    function setupDragAndDrop(element, handleContainer) {
        // Create a drag handle
        const dragHandle = document.createElement('div');
        dragHandle.className = 'drag-handle';
        dragHandle.innerHTML = '⋮⋮';
        dragHandle.title = 'Drag to move';

        // Style the drag handle
        Object.assign(dragHandle.style, {
            // No position: absolute. It will be positioned by its flex container.
            width: '24px',
            height: '20px',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            cursor: 'move',
            color: '#666',
            fontSize: '16px',
            fontWeight: 'bold',
            userSelect: 'none',
            touchAction: 'none',
            borderRadius: '4px' // Add rounding for a better look
        });

        // Mouse events
        dragHandle.addEventListener('mousedown', startDrag);
        document.addEventListener('mousemove', drag);
        document.addEventListener('mouseup', stopDrag);

        // Touch events for mobile
        dragHandle.addEventListener('touchstart', startDrag, { passive: false });
        document.addEventListener('touchmove', drag, { passive: false });
        document.addEventListener('touchend', stopDrag);

        // Add the drag handle to the provided container, *above* other items
        handleContainer.prepend(dragHandle);

        // No longer need to adjust content padding
    }

    // --- Main Calculation Logic ---
    async function calculateStatistics() {
        // Only calculate if we're on a portfolio page
        if (!isPortfolioPage()) {
            console.log('Skipping calculation - not on portfolio page');
            // Remove any existing notification
            const oldNotification = document.getElementById(notificationId);
            if (oldNotification) oldNotification.remove();
            return;
        }

        try {
            // Wait for essential elements to be available
            const elementsReady = await waitForElements([portfolioValueSelector, unrealizedGainSelector]);

            if (!elementsReady) {
                throw new Error('Required elements not found after waiting');
            }

            // 1. Extract Current Portfolio Value (get last active element)
            const portfolioValueElement = getLastElement(portfolioValueSelector);
            if (!portfolioValueElement) throw new Error(`Portfolio Value element not found with selector "${portfolioValueSelector}".`);
            const portfolioValue = parseCurrency(portfolioValueElement.innerText);
            if (isNaN(portfolioValue)) throw new Error(`Could not parse Portfolio Value from "${portfolioValueElement.innerText}".`);

            // 2. Extract Unrealized Gain (get last active element)
            const unrealizedGainElements = document.querySelectorAll(unrealizedGainSelector);
            const activeUnrealizedElements = Array.from(unrealizedGainElements).filter(el => {
                const style = window.getComputedStyle(el);
                return style.opacity !== '0' &&
                       style.visibility !== 'hidden' &&
                       style.display !== 'none' &&
                       !el.closest('[style*="opacity: 0"]') &&
                       !el.closest('[style*="visibility: hidden"]');
            });

            if (activeUnrealizedElements.length < 2) throw new Error(`Unrealized Gain element not found with selector "${unrealizedGainSelector}" at index 1.`);
            const unrealizedGainElement = activeUnrealizedElements[1];
            const unrealizedGainText = unrealizedGainElement.innerText.split('\n')[0];
            const unrealizedGain = parseCurrency(unrealizedGainText);
            if (isNaN(unrealizedGain)) throw new Error(`Could not parse Unrealized Gain value from "${unrealizedGainText}".`);

            // 3. Calculate Realized Gain (handles cases where it's not found)
            let realizedGain = 0;
            const realizedGainElements = document.querySelectorAll(realizedGainSelector);
            const activeRealizedElements = Array.from(realizedGainElements).filter(el => {
                const style = window.getComputedStyle(el);
                return style.opacity !== '0' &&
                       style.visibility !== 'hidden' &&
                       style.display !== 'none' &&
                       !el.closest('[style*="opacity: 0"]') &&
                       !el.closest('[style*="visibility: hidden"]');
            });

            if (activeRealizedElements.length > 0) {
                activeRealizedElements.forEach(el => {
                    const text = el.innerText.trim();
                    if (text.startsWith('+') || text.startsWith('-')) {
                        const value = parseFloat(text.replace(',', '.'));
                        if (!isNaN(value)) {
                            realizedGain += value;
                        }
                    }
                });
            }

            // 4. Calculate All Statistics
            const totalInvested = portfolioValue - unrealizedGain - realizedGain;
            const totalGain = realizedGain + unrealizedGain;

            const pctRealized = totalInvested === 0 ? 0 : (realizedGain / totalInvested) * 100;
            const pctUnrealized = totalInvested === 0 ? 0 : (unrealizedGain / totalInvested) * 100;
            const pctTotalGain = totalInvested === 0 ? 0 : (totalGain / totalInvested) * 100;

            // 5. Prepare and Display the Results
            const results = {
                portfolioValue: portfolioValue.toFixed(2),
                totalInvested: totalInvested.toFixed(2),
                realizedGain: realizedGain.toFixed(2),
                pctRealized: pctRealized.toFixed(2) + '%',
                unrealizedGain: unrealizedGain.toFixed(2),
                pctUnrealized: pctUnrealized.toFixed(2) + '%',
                totalGain: totalGain.toFixed(2),
                pctTotalGain: pctTotalGain.toFixed(2) + '%',
                // Show hint icon if realized gain is 0 and the activity tab isn't selected
                showActivityHint: realizedGain === 0 && !isActivityTabSelected
            };
            displayNotification(results, 'success');

        } catch (error) {
            console.error("Userscript Error:", error);
            displayNotification({ error: error.message }, "error");
        }
    }

    // --- UI Function ---
    function displayNotification(data, type = "success") {
        const oldNotification = document.getElementById(notificationId);
        if (oldNotification) oldNotification.remove();

        const notification = document.createElement('div');
        notification.id = notificationId;
        currentNotification = notification;

        const content = document.createElement('div');
        if (type === 'error') {
            content.innerHTML = `<strong>Error:</strong><br><small>${data.error}</small>`;
        } else {
            let realizedGainHtml;
            if (data.showActivityHint) {
                realizedGainHtml = `
                    <div class="value-with-icon">
                        <span>📈 Realized Gain:
                        <div class="info-icon" tabindex="0">
                            i
                            <div class="info-tooltip">Select the 'Activity' tab to include any realized P/L in the calculation.</div>
                        </div>
                        </span>
                    </div>
                `;
            } else {
                realizedGainHtml = `<span>📈 Realized Gain:</span>`;
            }

            content.innerHTML = `
                <div class="stat-line"><span>🏦 Portfolio Value:</span> <strong>${data.portfolioValue}</strong></div>
                <div class="stat-line"><span>💵 Total Invested:</span> <strong>${data.totalInvested}</strong></div>
                <hr>
                <div class="stat-line"><span>🌱 Unrealized Gain:</span> <strong>${data.unrealizedGain}</strong></div>
                <div class="stat-line"><span>📊 Pct Unrealized:</span> <strong>${data.pctUnrealized}</strong></div>
                <hr>
                <div class="stat-line">${realizedGainHtml} <strong>${data.realizedGain}</strong></div>
                <div class="stat-line"><span>📊 Pct Realized:</span> <strong>${data.pctRealized}</strong></div>
                <hr>
                <div class="stat-line"><span>💰 Total Gain:</span> <strong>${data.totalGain}</strong></div>
                <div class="stat-line"><span>🚀 Pct Total Gain:</span> <strong>${data.pctTotalGain}</strong></div>
            `;
        }

        const controlsWrapper = document.createElement('div');
        controlsWrapper.className = 'controls-wrapper';

        const closeButton = document.createElement('span');
        closeButton.textContent = '×';
        closeButton.onclick = () => {
            notification.remove();
            currentNotification = null;
        };
        controlsWrapper.appendChild(closeButton);

        notification.appendChild(content);
        notification.appendChild(controlsWrapper);
        document.body.appendChild(notification);

        // Setup drag and drop, placing the handle in the controls wrapper
        setupDragAndDrop(notification, controlsWrapper);

        const style = document.createElement('style');
        style.innerHTML = `
          #${notificationId} {
            position: fixed !important;
            top: 68px;
            right: 20px;
            padding: 16px;
            background-color: ${type === 'error' ? '#c82333' : '#f8f9fa'};
            color: black;
            border-radius: 8px;
            z-index: 99999;
            font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            font-size: 16px;
            line-height: 1.7;
            box-shadow: 0 6px 12px rgba(0,0,0,0.25);
            display: flex;
            align-items: flex-start;
            gap: 8px; /* Gap between content and controls */
            user-select: none;
            touch-action: none;
            min-width: 280px;
            max-width: 350px;
          }

          #${notificationId}.dragging {
            box-shadow: 0 12px 24px rgba(0,0,0,0.4);
            transform: scale(1.02);
            transition: transform 0.1s ease;
          }

          #${notificationId} .controls-wrapper {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px; /* Space between drag handle and close button */
          }

          #${notificationId} .stat-line {
            display: flex;
            justify-content: space-between;
            align-items: center; /* Align items vertically */
            gap: 20px;
          }

          #${notificationId} hr {
            border: none;
            border-top: 1px solid #444;
            margin: 8px 0;
          }

          /* --- Styles for the info icon and tooltip --- */
          #${notificationId} .value-with-icon {
            display: flex;
            align-items: center;
            gap: 8px;
          }

          #${notificationId} .info-icon {
            position: relative;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 16px;
            height: 16px;
            border-radius: 50%;
            background-color: #e0e0e0;
            color: #616161;
            font-size: 11px;
            font-weight: bold;
            font-style: italic;
            cursor: pointer;
            user-select: none;
            outline: none;
          }

          #${notificationId} .info-icon:hover,
          #${notificationId} .info-icon:focus {
            background-color: #c0c0c0;
          }

          #${notificationId} .info-tooltip {
            visibility: hidden;
            opacity: 0;
            width: 220px;
            background-color: #333;
            color: #fff;
            text-align: center;
            border-radius: 6px;
            padding: 10px;
            position: absolute;
            z-index: 10;
            bottom: 150%;
            left: 50%;
            transform: translateX(-50%);
            transition: opacity 0.3s, visibility 0.3s;
            font-size: 13px;
            line-height: 1.4;
            font-weight: normal;
            font-style: normal;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            pointer-events: none;
          }

          #${notificationId} .info-tooltip::after { /* Tooltip arrow */
            content: '';
            position: absolute;
            top: 100%;
            left: 50%;
            margin-left: -5px;
            border-width: 5px;
            border-style: solid;
            border-color: #333 transparent transparent transparent;
          }

          #${notificationId} .info-icon:hover .info-tooltip,
          #${notificationId} .info-icon:focus .info-tooltip {
            visibility: visible;
            opacity: 1;
          }
          /* --- End of info styles --- */

          #${notificationId} .drag-handle:hover {
            color: #333;
            background-color: rgba(0,0,0,0.1);
          }

          #${notificationId} .drag-handle:active {
            color: #000;
            background-color: rgba(0,0,0,0.2);
          }

          /* Mobile-specific styles */
          @media (max-width: 768px) {
            #${notificationId} {
              font-size: 14px;
              min-width: 260px;
              max-width: calc(100vw - 40px);
            }

            #${notificationId} .drag-handle {
              width: 24px !important;
              height: 24px !important;
              font-size: 16px !important;
            }
          }
        `;
        document.head.appendChild(style);

        Object.assign(content.style, {
            display: 'flex',
            flexDirection: 'column',
            gap: '4px',
            flex: '1'
        });

        Object.assign(closeButton.style, {
            fontSize: '24px',
            fontWeight: 'bold',
            cursor: 'pointer',
            opacity: '0.8',
            lineHeight: '0.8',
            minWidth: '24px',
            textAlign: 'center',
            userSelect: 'none'
        });
    }

    // --- URL Change Detection ---
    function handleUrlChange() {
        const newUrl = window.location.href;
        if (newUrl !== currentUrl) {
            currentUrl = newUrl;
            console.log('URL changed to:', currentUrl);

            // Remove existing notification when URL changes
            const oldNotification = document.getElementById(notificationId);
            if (oldNotification) {
                oldNotification.remove();
                currentNotification = null;
            }

            // Clear any pending calculations
            if (calculationTimeout) {
                clearTimeout(calculationTimeout);
            }

            // Reset activity tab state
            isActivityTabSelected = false;

            // Only calculate if we're on a portfolio page
            if (!isPortfolioPage()) {
                console.log('Navigated away from portfolio page - skipping calculation');
                return;
            }

            // Wait a bit for the new portfolio to load, then calculate
            calculationTimeout = setTimeout(() => {
                calculateStatistics();
            }, 1500);
        }
    }

    // --- Initialize ---
    function init() {
        // Initial calculation when script loads (only on portfolio pages)
        setTimeout(() => {
            if (isPortfolioPage()) {
                calculateStatistics();
                // Check initial activity tab state
                isActivityTabSelected = checkActivityTab();
            } else {
                console.log('Started on non-portfolio page - skipping initial calculation');
            }
        }, 1000);

        // Monitor for URL changes and DOM changes (for SPA navigation and activity tab changes)
        const observer = new MutationObserver((mutations) => {
            // Check for URL changes
            handleUrlChange();

            // Check for activity tab changes
            mutations.forEach((mutation) => {
                if (mutation.type === 'attributes' &&
                    mutation.attributeName === 'aria-selected' &&
                    mutation.target.matches(activityTabSelector)) {
                    handleActivityTabChange();
                } else if (mutation.type === 'childList') {
                    // Also check when new elements are added (in case tabs are dynamically created)
                    setTimeout(handleActivityTabChange, 100);
                }
            });
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['aria-selected']
        });

        // Also listen for popstate events
        window.addEventListener('popstate', handleUrlChange);

        // Override pushState and replaceState to catch programmatic navigation
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function(...args) {
            originalPushState.apply(history, args);
            setTimeout(handleUrlChange, 100);
        };

        history.replaceState = function(...args) {
            originalReplaceState.apply(history, args);
            setTimeout(handleUrlChange, 100);
        };
    }

    // Start the script when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();