Google Finance Statistics

Display comprehensive portfolio statistics on Google Finance

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
    }
})();