GOG - Price Charts

Fetches price history from GOGDB.org to generate price charts for games on GOG

// ==UserScript==
// @name            GOG - Price Charts
// @namespace       https://github.com/idkicarus/
// @homepageURL     https://github.com/idkicarus/GOG-price-charts
// @supportURL      https://github.com/idkicarus/GOG-price-charts/issues
// @match           https://www.gog.com/*/game/*
// @description     Fetches price history from GOGDB.org to generate price charts for games on GOG
// @version         1.0
// @grant           GM.xmlHttpRequest
// @require         https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @require         https://cdn.jsdelivr.net/npm/[email protected]
// @license MIT
// ==/UserScript==

/* global Chart */

(function() {

    const DEBUG_MODE = false; // Enable debug mode for logging errors and status messages during script execution.
    const CACHE_KEY_PREFIX = "gogdb_price_"; // Define a prefix for cache keys used to store API responses. This helps uniquely identify data for specific products.
    const CACHE_LENGTH = 1000 * 60 * 60 * 24; // 24 hours in milliseconds (1,000 ms per second, 60 s per minute, 60 mins per hour, 24 hrs per day)

    /**
     * Injects static styles into the document for elements used by the script.
     * This ensures that the custom UI elements have consistent styling.
     */
    function addStaticStyles() {
        // Define a block of CSS styles to be applied to the page.
        const staticStyles = `
            .gog_ph_shadow { box-shadow: 0 1px 5px rgba(0,0,0,.15); }
            .gog_ph_whitebg { background-color: #e1e1e1; }
            #gog_ph_div { max-height: 300px; overflow: hidden; margin-bottom: 20px; width: 100%; height: 300px; }
            #gog_ph_chart_canvas {
                max-height: 200px;
                visibility: hidden;
                width: 100%;
            }
            #gog_ph_placeholder {
                height: 200px;
                background-color: #e1e1e1;
                display: flex;
                justify-content: center;
                align-items: center;
                border-radius: 5px;
            }
        `;

        // Create a <style> element to hold the CSS styles.
        const styleTag = document.createElement('style');
        styleTag.textContent = staticStyles;
        document.head.appendChild(styleTag);

        // Create a container div for the price history chart and related elements.
        const gog_ph_div = document.createElement("div");
        gog_ph_div.setAttribute("id", "gog_ph_div");
        gog_ph_div.innerHTML = `
            <div class="title">
                <div class="title__underline-text">Price history</div>
                <div class="title__additional-options"></div>
            </div>
            <div id="gog_ph_placeholder" class="gog_ph_whitebg gog_ph_shadow">Loading price history...</div>
            <canvas id="gog_ph_chart_canvas" class="gog_ph_whitebg gog_ph_shadow"></canvas>
            <p style="margin-top: 10px;">
                <span id="gog_ph_lowest_price"></span>
                <span id="gog_ph_data_source"></span>
            </p>
        `;

        // Append the container div to the body of the document.
        document.body.appendChild(gog_ph_div);
    }

    /**
     * Moves the placeholder to a specific location in the DOM once the target is available.
     * This ensures the price history UI is displayed in the appropriate section of the page.
     */
    function relocatePlaceholder() {
        const placeholderDiv = document.getElementById("gog_ph_div");

        // Use a MutationObserver to monitor DOM changes and relocate the placeholder when possible.
        const observer = new MutationObserver(() => {
            const targetElement = document.querySelector("div.layout-container:nth-child(9)");
            if (targetElement && placeholderDiv) {
                targetElement.prepend(placeholderDiv);
                observer.disconnect(); // Stop observing once the placeholder is relocated.
            }
        });

        // Check the document's readiness state and start observing accordingly.
        if (document.readyState === "loading") {
            document.addEventListener("DOMContentLoaded", () => {
                observer.observe(document.documentElement, {
                    childList: true,
                    subtree: true
                });
            });
        } else {
            observer.observe(document.documentElement, {
                childList: true,
                subtree: true
            });
        }
    }

    /**
     * Waits for the product data to be loaded, then triggers the provided callback.
     * The product data is required to fetch the price history for a specific game.
     * @param {Function} callback - Function to call with the product ID once available.
     */
    function waitForProductData(callback) {
        let pollingInterval;

        // Start polling for product data once the window has loaded.
        window.addEventListener("load", () => {
            pollingInterval = setInterval(() => {
                try {
                    // Check if the productcardData object is available and contains the product ID.
                    if (unsafeWindow.productcardData?.cardProductId) {
                        clearInterval(pollingInterval); // Stop polling once the data is available.
                        callback(unsafeWindow.productcardData.cardProductId);
                    }
                } catch (error) {
                    if (DEBUG_MODE) console.error("Error accessing productcardData:", error);
                    clearInterval(pollingInterval);
                }
            }, 100); // Poll every 100ms.
        });

        // Set a timeout to stop polling if the data is not loaded within 10 seconds.
        setTimeout(() => {
            if (pollingInterval) {
                clearInterval(pollingInterval);
                if (DEBUG_MODE) console.warn("Timeout: Unable to load product data.");
            }
        }, 10000);
    }

    /**
     * Fetches price history data from the GOGDB API for a given product.
     * @param {string} cacheKey - Key to cache the API response.
     * @param {string} productId - Product ID to fetch data for.
     */
    function fetchPriceData(cacheKey, productId) {
        const cachedData = localStorage.getItem(cacheKey);
        const cacheTimestamp = localStorage.getItem(`${cacheKey}_timestamp`);

        // Check if cached data is available and not expired (e.g., 24 hours).
        if (cachedData && cacheTimestamp && Date.now() - cacheTimestamp < CACHE_LENGTH) {
            if (DEBUG_MODE) console.log("Using cached data:", JSON.parse(cachedData));
            processPriceData(JSON.parse(cachedData), productId);
            return;
        }

        // Fetch data from the API if no valid cache is found.
        GM.xmlHttpRequest({
            method: "GET",
            url: `https://www.gogdb.org/data/products/${productId}/prices.json`,
            onload: function(response) {
                if (response.status === 200) {
                    const jsonData = JSON.parse(response.responseText);
                    if (DEBUG_MODE) console.log("Fetched price data:", jsonData);

                    // Cache the response and timestamp.
                    localStorage.setItem(cacheKey, response.responseText);
                    localStorage.setItem(`${cacheKey}_timestamp`, Date.now());

                    processPriceData(jsonData, productId);
                } else {
                    document.getElementById("gog_ph_placeholder").textContent =
                        response.status === 404 ?
                        "No historical price data available." :
                        "Failed to load price history.";
                }
            },
            onerror: function() {
                document.getElementById("gog_ph_placeholder").textContent =
                    "Failed to load price history.";
            },
        });
    }


    /**
     * Processes the price history data and updates the UI with a chart and additional information.
     * @param {Object} jsonData - Raw price data fetched from the API.
     * @param {string} productId - Product ID associated with the data.
     */
    function processPriceData(jsonData, productId) {
        // Parse the price history data into labels (dates), prices, and key metrics.
        const {
            labels,
            prices,
            lowestPrice,
            highestBasePrice
        } = parsePriceHistory(jsonData);

        if (DEBUG_MODE) {
            console.log("Processed labels:", labels);
            console.log("Processed prices:", prices);
        }

        // Check if there is valid data to display.
        if (labels.length > 0 && prices.length > 0) {
            // Create the price history chart using Chart.js.
            createChart(labels, prices, highestBasePrice);
            document.getElementById("gog_ph_placeholder").remove();

            // Update the lowest price and data source information in the UI.
            const lowestPriceElement = document.getElementById("gog_ph_lowest_price");
            const dataSourceElement = document.getElementById("gog_ph_data_source");

            if (lowestPrice > 0 && lowestPrice < highestBasePrice) {
                // Display the lowest price if it is valid and less than the highest base price.
                lowestPriceElement.textContent = `Historical low: $${lowestPrice.toFixed(2)}.`;
                dataSourceElement.innerHTML = ` (Data retrieved from <a id="gog_ph_gogdb_link" class="un" href="https://www.gogdb.org/product/${productId}" target="_blank"><u>GOG Database</u></a>.)`;
            } else {
                lowestPriceElement.textContent = "";
                dataSourceElement.innerHTML = `Data retrieved from <a id="gog_ph_gogdb_link" class="un" href="https://www.gogdb.org/product/${productId}" target="_blank"><u>GOG Database</u></a>.`;
            }
        } else {
            // Display a message if no price history data is available.
            document.getElementById("gog_ph_placeholder").textContent =
                "No historical price data available.";
        }
    }

    /**
	 * Parses the price history data into labels, prices, and key metrics.
	 * @param {Object} jsonData - Raw price data from the API.
	 * @returns {Object} Parsed data including labels, prices, lowest price, and highest base price.
	 */
	function parsePriceHistory(jsonData) {
		const history = jsonData?.US?.USD || []; // Extract the price history for USD or default to an empty array.
		const labels = []; // Array to store dates for the x-axis of the chart.
		const prices = []; // Array to store prices for the y-axis of the chart.
		let lowestPrice = Infinity; // Initialize the lowest price to a very high value.
		let highestBasePrice = 0; // Initialize the highest base price to zero.

    // Handle the edge case where there is only one entry in the price history by creating a second data point at the current date.
    if (history.length === 1) {
        const singleEntry = history[0];
        const originalDate = new Date(singleEntry.date);
        labels.push(originalDate);

        const currentDate = new Date();
        labels.push(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1));

		// Create constants for price_base and price_final converted to decimals
        const singlePriceBase = singleEntry.price_base / 100;
        const singlePriceFinal = singleEntry.price_final / 100;

		// Determine the lowest price between price_base and price_final, then store it. Handles games with launch/pre-launch discounts.
		//Probably better than defaulting to price_final for all games with a single price entry.
        lowestPrice = Math.min(singlePriceBase, singlePriceFinal);
        highestBasePrice = singlePriceBase;

        prices.push(lowestPrice, lowestPrice); // Display the lower entry in the chart

        return {
            labels,
            prices,
            lowestPrice,
            highestBasePrice
        };
    }

        // Process each entry in the price history.
        history.forEach(entry => {
            const date = new Date(entry.date); // Convert the entry date string to a Date object.
            const price = entry.price_final ? entry.price_final / 100 : null; // Convert the final price to a number if available.

            if (price && price > 0) {
                labels.push(date); // Add the date to the labels array.
                prices.push(price); // Add the price to the prices array.
                lowestPrice = Math.min(lowestPrice, price); // Update the lowest price if the current price is lower.
                highestBasePrice = Math.max(highestBasePrice, entry.price_base / 100 || 0); // Update the highest base price.
            }
        });

        return {
            labels,
            prices,
            lowestPrice: lowestPrice === Infinity ? 0 : lowestPrice, // Handle the case where no valid prices are found.
            highestBasePrice,
        };
    }

    /**
     * Creates a chart using Chart.js with multiple data points.
     * @param {Array} labels - Array of dates for the x-axis.
     * @param {Array} prices - Array of prices for the y-axis.
     * @param {number} highestBasePrice - Highest base price for scaling.
     */
    function createChart(labels, prices, highestBasePrice) {
        if (labels.length === 2) {
            // Handle the case where there are only two data points.
            createChartSingleEntry(labels, prices);
        } else {
            // Handle the case with multiple data points.
            createChartMultipleEntries(labels, prices);
        }
    }

    /**
     * Creates a chart for multiple price entries.
     * @param {Array} labels - Array of dates for the x-axis.
     * @param {Array} prices - Array of prices for the y-axis.
     */
    function createChartMultipleEntries(labels, prices) {
        const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.

        new Chart(ctx, {
            type: "line", // Specify the chart type as a line chart.
            data: {
                labels, // Use the provided labels for the x-axis.
                datasets: [{
                    label: "Price", // Label for the dataset.
                    borderColor: "rgb(241, 142, 0)", // Set the line color.
                    backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
                    data: prices, // Use the provided prices for the y-axis.
                    stepped: true, // Use stepped lines to indicate discrete changes.
                    fill: false, // Disable filling under the line.
                }],
            },
            options: {
                scales: {
                    x: {
                        type: "time", // Use a time scale for the x-axis.
                        time: {
                            tooltipFormat: "MMM d, yyyy", // Format for tooltips (i.e., short month, day, 4-digit year).
                            displayFormats: {
                                month: "MMM yyyy", // Format for month labels (i.e., short month, 4-digit year).
                            },
                        },
                        ticks: {
                            autoSkip: true, // Automatically skip ticks to avoid overcrowding.
                            maxTicksLimit: Math.floor(labels.length / 3), // Limit the number of ticks based on the data length.
                        },
                    },
                    y: {
                        beginAtZero: true, // Start the y-axis at zero.
                        ticks: {
                            callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
                        },
                    },
                },
                plugins: {
                    legend: {
                        display: false, // Disable the legend for simplicity.
                    },
                },
                maintainAspectRatio: false, // Allow the chart to resize dynamically.
            },
        });

        document.getElementById("gog_ph_chart_canvas").style.visibility = "visible"; // Make the chart visible after rendering.
    }

    /**
     * Creates a chart for a single price entry.
     * @param {Array} labels - Array of two dates for the x-axis.
     * @param {Array} prices - Array of two prices for the y-axis.
     */
    function createChartSingleEntry(labels, prices) {
        const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.

        new Chart(ctx, {
            type: "line", // Specify the chart type as a line chart.
            data: {
                labels, // Use the provided labels for the x-axis.
                datasets: [{
                    label: "Price", // Label for the dataset.
                    borderColor: "rgb(241, 142, 0)", // Set the line color.
                    backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
                    data: prices, // Use the provided prices for the y-axis.
                    stepped: true, // Use stepped lines to indicate discrete changes.
                    fill: false, // Disable filling under the line.
                }],
            },
            options: {
                scales: {
                    x: {
                        type: "category", // Use a category scale for the x-axis.
                        ticks: {
                            autoSkip: false, // Do not skip ticks.
                            callback: function(value, index) {
                                return labels[index].toLocaleDateString("en-US", {
                                    month: "short",
                                    year: "numeric",
                                }); // Format x-axis labels as short month and year.
                            },
                        },
                    },
                    y: {
                        beginAtZero: true, // Start the y-axis at zero.
                        ticks: {
                            callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
                        },
                    },
                },
                plugins: {
                    legend: {
                        display: false, // Disable the legend for simplicity.
                    },
                    tooltip: {
                        callbacks: {
                            title: function(context) {
                                const index = context[0].dataIndex;
                                // Format the tooltip title to display the date as "Month Day, Year"
                                return labels[index].toLocaleDateString("en-US", {
                                    month: "short",
                                    day: "numeric",
                                    year: "numeric",
                                });
                            },
                        },
                    },
                },
                maintainAspectRatio: false, // Allow the chart to dynamically resize without distorting the aspect ratio.
            },
        });

        // Make the chart visible once rendering is complete.
        document.getElementById("gog_ph_chart_canvas").style.visibility = "visible";
    }

    // Add the necessary static styles to the page.
    addStaticStyles();
    // Relocate the placeholder element to its correct position on the page.
    relocatePlaceholder();
    // Wait for the product data to load and then fetch the price history data for the product.
    waitForProductData(productId => {
        fetchPriceData(`${CACHE_KEY_PREFIX}${productId}`, productId);
    });
})();