Olympus-Zendesk Budget Sync latest version (currently v2.0.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Olympus-Zendesk: Budget Sync
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.0.0
// @description  Syncs Olympus project budget data to Zendesk ticket headers and a dashboard on timhilton.xyz. Supports clients with multiple projects, with a per-ticket project switcher.
// @author       Tim Hilton using GitHub Copilot
// @match        https://olympus.audacia.co.uk/*
// @match        https://*.zendesk.com/agent/tickets/*
// @match        https://www.timhilton.xyz/tools/olympus-budget-dashboard/*
// @match        http://localhost:8080/tools/olympus-budget-dashboard/*
// @include      https://personal-website-*.vercel.app/tools/olympus-budget-dashboard/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        unsafeWindow
// ==/UserScript==

(function() {
    'use strict';

    // Use unsafeWindow for proper access to page context
    const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

    const SCRIPT_VERSION = '2.0.0';
    const STORAGE_KEY = 'olympusClientBudgets';
    const STORAGE_KEY_CAPTURED_AT = 'olympusClientBudgetsCapturedAt';
    const isOlympus = window.location.hostname === 'olympus.audacia.co.uk';
    const isZendesk = window.location.hostname.endsWith('.zendesk.com');
    // Any page we run on that is not Olympus or Zendesk is the dashboard: the
    // @match/@include list above is what gates which pages that can be.
    const isDashboard = !isOlympus && !isZendesk;

    const LOG_PREFIX_OLYMPUS = '[Olympus-Zendesk: Budget Sync (Olympus)]';
    const LOG_PREFIX_ZENDESK = '[Olympus-Zendesk: Budget Sync (Zendesk)]';
    const LOG_PREFIX_DASHBOARD = '[Olympus-Zendesk: Budget Sync (Dashboard)]';

    let activeLogPrefix = LOG_PREFIX_DASHBOARD;
    if (isOlympus) {
        activeLogPrefix = LOG_PREFIX_OLYMPUS;
    } else if (isZendesk) {
        activeLogPrefix = LOG_PREFIX_ZENDESK;
    }

    console.debug(`${activeLogPrefix} Script initializing...`, {
        hostname: window.location.hostname,
        isOlympus,
        isZendesk,
        isDashboard,
        url: window.location.href,
        usingUnsafeWindow: typeof unsafeWindow !== 'undefined'
    });

    // ====================================================================
    // SHARED: Budget and month progress maths
    // ====================================================================
    // NOTE: These helpers are deliberately duplicated in the Olympus Budget
    // Dashboard tool (src/tools/olympus-budget-dashboard//js/budget-dashboard.js)
    // because the userscript and the tool run in different contexts with no
    // shared build step. Keep the two copies in sync.

    /**
     * Determine whether a date falls on a weekend
     * @param {Date} date - The date to test
     * @returns {boolean} True if the date is a Saturday or Sunday
     */
    function isWeekend(date) {
        const day = date.getDay();
        return day === 0 || day === 6;
    }

    /**
     * Count the working days (Mon-Fri) in the month containing a date
     * @param {Date} date - Any date within the month of interest
     * @returns {number} Number of working days in that month
     */
    function getWorkingDaysInMonth(date) {
        const year = date.getFullYear();
        const month = date.getMonth();
        const daysInMonth = new Date(year, month + 1, 0).getDate();
        let count = 0;
        for (let day = 1; day <= daysInMonth; day++) {
            if (!isWeekend(new Date(year, month, day))) count++;
        }
        return count;
    }

    /**
     * Count the working days (Mon-Fri) elapsed in the month up to and including a date
     * @param {Date} date - The date to count up to
     * @returns {number} Number of working days elapsed
     */
    function getWorkingDaysElapsed(date) {
        const year = date.getFullYear();
        const month = date.getMonth();
        let count = 0;
        for (let day = 1; day <= date.getDate(); day++) {
            if (!isWeekend(new Date(year, month, day))) count++;
        }
        return count;
    }

    /**
     * Calculate month progress as a percentage of working days
     * Bank holidays are not accounted for - there is no data source for them.
     * @param {Date} [date] - The date to measure progress at (defaults to now)
     * @returns {number} Percentage of the month's working days elapsed (0-100)
     */
    function getMonthProgressPercentage(date = new Date()) {
        const total = getWorkingDaysInMonth(date);
        if (total === 0) return 0;
        return (getWorkingDaysElapsed(date) / total) * 100;
    }

    /**
     * Calculate budget spend progress as a percentage
     * @param {number} booked - Days booked
     * @param {number} scheduled - Days scheduled
     * @returns {number} Percentage of scheduled days that are booked (0+, can exceed 100).
     *                   Infinity when there is no budget but time has been booked.
     */
    function getBudgetProgressPercentage(booked, scheduled) {
        if (!scheduled || scheduled <= 0) {
            return booked > 0 ? Number.POSITIVE_INFINITY : 0;
        }
        return (booked / scheduled) * 100;
    }

    /**
     * Determine the color for the budget bar based on budget and month percentages
     * @param {number} budgetPercentage - Budget progress percentage
     * @param {number} monthPercentage - Month progress percentage
     * @returns {string} Hex color code
     */
    function getBudgetBarColor(budgetPercentage, monthPercentage) {
        // Red if > 100% (or no budget at all, which reports as Infinity)
        if (!Number.isFinite(budgetPercentage) || budgetPercentage > 100) {
            return '#ef4444'; // red
        }

        // Green if < 25% OR at least 20 percentage points lower than month bar
        if (budgetPercentage < 25 || (monthPercentage - budgetPercentage) >= 20) {
            return '#22c55e'; // green
        }

        // Orange if > 75% OR 20 percentage points or more above the month bar
        if (budgetPercentage > 75 || (budgetPercentage - monthPercentage) >= 20) {
            return '#f97316'; // orange
        }

        // Otherwise blue
        return '#3b82f6'; // blue
    }

    // ====================================================================
    // OLYMPUS: Budget Data Sync
    // ====================================================================
    if (isOlympus) {
        const API_URL = 'https://olympus-api.audacia.co.uk/api/projects/widget';

        // Retained so the payload shape can be confirmed via olympusBudgetSync.raw()
        let lastRawPayload = null;

        /**
         * Extract budget data from API response
         * @param {Array} projects - Array of project objects from the API
         * @returns {Array} Array of simplified budget objects
         */
        function extractBudgetData(projects) {
            console.debug(`${LOG_PREFIX_OLYMPUS} extractBudgetData called with:`, projects);

            if (!Array.isArray(projects)) {
                console.debug(`${LOG_PREFIX_OLYMPUS} Expected an array but received: ${typeof projects}`);
                return [];
            }

            const extracted = projects.map(project => ({
                clientName: project.clientName || '',
                projectName: project.projectName || '',
                daysBooked: project.daysBooked || 0,
                daysScheduled: project.daysScheduled || 0
            }));

            console.debug(`${LOG_PREFIX_OLYMPUS} Extracted ${extracted.length} budget entries`);
            return extracted;
        }

        /**
         * Save budget data to GM storage
         * @param {Array} budgetData - Array of budget objects to store
         */
        async function saveBudgetData(budgetData) {
            try {
                console.debug(`${LOG_PREFIX_OLYMPUS} Attempting to save budget data:`, budgetData);
                await GM.setValue(STORAGE_KEY, JSON.stringify(budgetData));
                await GM.setValue(STORAGE_KEY_CAPTURED_AT, Date.now());
                console.log(`${LOG_PREFIX_OLYMPUS} ✅ Successfully saved ${budgetData.length} client budgets to storage`);

                // Verify the data was saved
                const verified = await GM.getValue(STORAGE_KEY);
                console.debug(`${LOG_PREFIX_OLYMPUS} Verification read from storage:`, verified);
            } catch (error) {
                console.log(`${LOG_PREFIX_OLYMPUS} ❌ Error saving data to storage:`, error);
            }
        }

        /**
         * Process the API response and store data
         * @param {Array} data - API response data
         */
        async function processApiResponse(data) {
            lastRawPayload = data;
            const budgetData = extractBudgetData(data);
            if (budgetData.length > 0) {
                await saveBudgetData(budgetData);
                console.log(`${LOG_PREFIX_OLYMPUS} 🎉 Successfully processed and stored budget data`);
            } else {
                console.log(`${LOG_PREFIX_OLYMPUS} ⏭️ No valid budget data to store`);
            }
        }

        /**
         * Override fetch to intercept API responses
         */
        const originalFetch = win.fetch;
        win.fetch = async function(...args) {
            const response = await originalFetch.apply(this, args);

            // Check if this is the projects widget API
            const url = args[0]?.url || args[0];
            console.debug(`${LOG_PREFIX_OLYMPUS} fetch intercepted, URL: ${url}`);

            if (typeof url === 'string' && url.includes('/api/projects/widget')) {
                console.debug(`${LOG_PREFIX_OLYMPUS} Detected projects widget API call, processing response...`);
                // Clone the response so we can read it without consuming it
                const clonedResponse = response.clone();
                try {
                    const data = await clonedResponse.json();
                    console.debug(`${LOG_PREFIX_OLYMPUS} Received API data:`, data);
                    await processApiResponse(data);
                } catch (e) {
                    console.log(`${LOG_PREFIX_OLYMPUS} ❌ Error parsing fetch response:`, e);
                }
            }

            return response;
        };

        /**
         * Override XMLHttpRequest to intercept API responses
         */
        const originalXHROpen = win.XMLHttpRequest.prototype.open;
        const originalXHRSend = win.XMLHttpRequest.prototype.send;

        win.XMLHttpRequest.prototype.open = function(method, url, ...rest) {
            this._budgetSyncUrl = url;
            return originalXHROpen.apply(this, [method, url, ...rest]);
        };

        win.XMLHttpRequest.prototype.send = function(...args) {
            if (this._budgetSyncUrl && this._budgetSyncUrl.includes('/api/projects/widget')) {
                console.debug(`${LOG_PREFIX_OLYMPUS} XHR to projects widget API detected: ${this._budgetSyncUrl}`);
                this.addEventListener('load', async function() {
                    try {
                        const data = JSON.parse(this.responseText);
                        console.debug(`${LOG_PREFIX_OLYMPUS} Received XHR data:`, data);
                        await processApiResponse(data);
                    } catch (e) {
                        console.log(`${LOG_PREFIX_OLYMPUS} ❌ Error parsing XHR response:`, e);
                    }
                });
            }
            return originalXHRSend.apply(this, args);
        };

        // Expose a function to manually view stored data for debugging
        console.debug(`${LOG_PREFIX_OLYMPUS} Setting up olympusBudgetSync object on window...`);
        win.olympusBudgetSync = {
            view: async function() {
                try {
                    console.debug(`${LOG_PREFIX_OLYMPUS} view() called, attempting to read from storage...`);
                    const data = await GM.getValue(STORAGE_KEY);
                    if (data) {
                        const parsed = JSON.parse(data);
                        console.log('Stored budget data:', parsed);
                        return parsed;
                    } else {
                        console.log('No budget data stored yet');
                        return null;
                    }
                } catch (e) {
                    console.log(`${LOG_PREFIX_OLYMPUS} ❌ Error reading stored data:`, e);
                    return null;
                }
            },
            raw: function() {
                if (lastRawPayload === null) {
                    console.log('No raw API response captured yet - load the Olympus projects widget first');
                    return null;
                }
                console.log('Last raw API response:', lastRawPayload);
                return lastRawPayload;
            },
            help: function() {
                console.log(`
Budget Sync - Console Commands:

  olympusBudgetSync.view()  - View the currently stored budget data
  olympusBudgetSync.raw()   - View the last raw API response, exactly as returned
  olympusBudgetSync.help()  - Show this help message

This script automatically intercepts GET requests to ${API_URL}
and stores client budget information for use in Zendesk and on the
Olympus Budget Dashboard at https://www.timhilton.xyz/tools/olympus-budget-dashboard/latest/
                `);
            }
        };

        console.debug(`${LOG_PREFIX_OLYMPUS} olympusBudgetSync object created:`, win.olympusBudgetSync);
        console.debug(`${LOG_PREFIX_OLYMPUS} Verifying window.olympusBudgetSync:`, window.olympusBudgetSync);
        console.log(`${LOG_PREFIX_OLYMPUS} 🎉 Loaded. Type olympusBudgetSync.help() for commands.`);
    }

    // ====================================================================
    // ZENDESK: Budget Display
    // ====================================================================
    if (isZendesk) {
        const BUDGET_SPAN_ID = 'olympus-budget-display';
        const CHECK_INTERVAL = 500; // Check for changes every 500ms
        const PROJECT_OVERRIDE_STORAGE_KEY = 'olympusBudgetSyncProjectOverrides';

        // Bar chart styling constants
        const BAR_WIDTH_PX = 100;
        const BAR_HEIGHT_PX = 8;
        const MAX_EXTENSION_PERCENTAGE = 50; // Maximum extension beyond 100% marker

        /**
         * Get the organization name from the ticket header
         * @returns {string|null} The organization name or null if not found
         */
        function getOrganizationName() {
            const orgElement = document.querySelector('[data-test-id="tabs-nav-item-organizations"]');
            if (orgElement) {
                const orgName = orgElement.innerText.trim();
                // Remove any badge/number indicators (e.g., "Organization (1)" -> "Organization")
                return orgName.replace(/\s*\(\d+\)\s*$/, '').trim();
            }
            return null;
        }

        /**
         * Get the current ticket ID from the page URL
         * @returns {string|null} The ticket ID, or null if the URL is not a ticket page
         */
        function getTicketId() {
            const match = window.location.pathname.match(/\/agent\/tickets\/(\d+)/);
            return match ? match[1] : null;
        }

        /**
         * Read the map of ticket ID -> manually-chosen project name from localStorage
         * @returns {Object} The stored overrides map, or an empty object if none is stored or it cannot be read
         */
        function getProjectOverrides() {
            try {
                const stored = localStorage.getItem(PROJECT_OVERRIDE_STORAGE_KEY);
                return stored ? JSON.parse(stored) : {};
            } catch (e) {
                console.log(`${LOG_PREFIX_ZENDESK} ❌ Error reading project overrides from localStorage:`, e);
                return {};
            }
        }

        /**
         * Get the project manually chosen for a ticket, if any
         * @param {string|null} ticketId - The ticket ID
         * @returns {string|null} The overridden project name, or null if none is stored
         */
        function getProjectOverride(ticketId) {
            if (!ticketId) return null;
            return getProjectOverrides()[ticketId] || null;
        }

        /**
         * Remember a manually-chosen project for a single ticket
         * @param {string} ticketId - The ticket ID
         * @param {string} projectName - The project name to remember for this ticket
         */
        function setProjectOverride(ticketId, projectName) {
            if (!ticketId) return;
            try {
                const overrides = getProjectOverrides();
                overrides[ticketId] = projectName;
                localStorage.setItem(PROJECT_OVERRIDE_STORAGE_KEY, JSON.stringify(overrides));
                console.debug(`${LOG_PREFIX_ZENDESK} Stored project override "${projectName}" for ticket ${ticketId}`);
            } catch (e) {
                console.log(`${LOG_PREFIX_ZENDESK} ❌ Error saving project override to localStorage:`, e);
            }
        }

        /**
         * Get stored budget data from GM storage
         * @returns {Promise} Array of budget objects or null if not found
         */
        async function getBudgetData() {
            try {
                console.debug(`${LOG_PREFIX_ZENDESK} Attempting to read budget data from storage...`);
                const data = await GM.getValue(STORAGE_KEY);
                if (data) {
                    const parsed = JSON.parse(data);
                    console.debug(`${LOG_PREFIX_ZENDESK} Retrieved ${parsed.length} budget entries from storage`);
                    return parsed;
                } else {
                    console.debug(`${LOG_PREFIX_ZENDESK} No budget data found in storage`);
                }
            } catch (e) {
                console.log(`${LOG_PREFIX_ZENDESK} ❌ Error reading stored data:`, e);
            }
            return null;
        }

        /**
         * Find all budget entries (one per project) for a specific client (case-insensitive match)
         * @param {Array} budgetData - Array of budget objects
         * @param {string} clientName - Client name to search for
         * @returns {Array} Budget objects for the client, in their original order; empty if none match
         */
        function findClientBudgets(budgetData, clientName) {
            if (!Array.isArray(budgetData) || !clientName) {
                return [];
            }

            const normalizedSearchName = clientName.toLowerCase();
            return budgetData.filter(
                budget => budget.clientName && budget.clientName.toLowerCase() === normalizedSearchName
            );
        }

        /**
         * Pick the default project for a client with one or more budget entries.
         * Prefers the project whose name contains "support" (case-insensitive) nearest
         * the start of the name, falling back to the first project if none match.
         * @param {Array} clientBudgets - Budget objects for a single client
         * @returns {Object} The chosen budget object
         */
        function pickDefaultProjectBudget(clientBudgets) {
            let defaultBudget = null;
            let bestSupportIndex = Infinity;

            for (const budget of clientBudgets) {
                const supportIndex = (budget.projectName || '').toLowerCase().indexOf('support');
                if (supportIndex !== -1 && supportIndex < bestSupportIndex) {
                    bestSupportIndex = supportIndex;
                    defaultBudget = budget;
                }
            }

            return defaultBudget || clientBudgets[0];
        }

        /**
         * Create a horizontal progress bar
         * @param {number} percentage - Progress percentage (0-100+, or Infinity for no budget)
         * @param {string} fillColor - Color for the fill bar
         * @returns {HTMLElement} The progress bar container element
         */
        function createProgressBar(percentage, fillColor) {
            // A non-finite percentage means time booked against no budget at all: treat it
            // as the maximum over-limit case rather than letting Infinity reach the styles.
            const hasFinitePercentage = Number.isFinite(percentage);
            const isOverLimit = !hasFinitePercentage || percentage > 100;
            let extensionPercentage = 0;
            if (isOverLimit) {
                extensionPercentage = hasFinitePercentage
                    ? Math.min(percentage - 100, MAX_EXTENSION_PERCENTAGE)
                    : MAX_EXTENSION_PERCENTAGE;
            }

            const container = document.createElement('div');
            container.style.cssText = `
                position: relative;
                width: ${BAR_WIDTH_PX + (isOverLimit ? (BAR_WIDTH_PX * extensionPercentage / 100) : 0)}px;
                height: ${BAR_HEIGHT_PX}px;
                background-color: #e5e7eb;
                border-radius: 2px;
                overflow: visible;
            `;

            // Create the 100% marker line
            const marker = document.createElement('div');
            marker.style.cssText = `
                position: absolute;
                left: ${BAR_WIDTH_PX}px;
                top: 0;
                bottom: 0;
                width: 1px;
                background-color: #6b7280;
                z-index: 1;
            `;
            container.appendChild(marker);

            // Create the fill bar
            const fill = document.createElement('div');
            const cappedPercentage = hasFinitePercentage ? Math.min(percentage, 100) : 100;
            fill.style.cssText = `
                position: absolute;
                left: 0;
                top: 0;
                bottom: 0;
                width: ${cappedPercentage}%;
                background-color: ${fillColor};
                border-radius: 2px;
                transition: width 0.3s ease;
            `;
            container.appendChild(fill);

            // If over 100%, add an extension bar
            if (isOverLimit) {
                const extension = document.createElement('div');
                extension.style.cssText = `
                    position: absolute;
                    left: ${BAR_WIDTH_PX}px;
                    top: 0;
                    bottom: 0;
                    width: ${extensionPercentage}px;
                    background-color: ${fillColor};
                    border-radius: 2px;
                    opacity: 0.8;
                `;
                container.appendChild(extension);
            }

            return container;
        }

        /**
         * Create or update the budget display span
         * @param {Object} budgetInfo - Budget information for the currently selected project
         * @param {Array} clientBudgets - All budget entries for the client (one per project); a
         *                                project switcher is only rendered when there is more than one
         * @param {string|null} ticketId - The current ticket ID, used to remember a manual project choice
         */
        function displayBudgetInfo(budgetInfo, clientBudgets, ticketId) {
            // Find the navigation bar where we'll add our span
            const navBar = document.querySelector('[data-test-id="tabs-nav-item-organizations"]')?.closest('[aria-label="Ticket page location"]');
            if (!navBar) {
                console.log(`${LOG_PREFIX_ZENDESK} ❌ Could not find navigation bar`);
                return;
            }

            // Check if budget span already exists
            let budgetSpan = document.getElementById(BUDGET_SPAN_ID);

            // Remove existing span if present
            if (budgetSpan) {
                budgetSpan.remove();
            }

            // Create new budget span
            budgetSpan = document.createElement('span');
            budgetSpan.id = BUDGET_SPAN_ID;
            budgetSpan.style.cssText = `
                display: inline-flex;
                align-items: center;
                margin-left: 12px;
                padding: 2px 8px;
                background-color: #f0f4ff;
                border: 1px solid #c7d7fe;
                border-radius: 4px;
                font-size: 12px;
                color: #1e40af;
                white-space: nowrap;
                gap: 8px;
            `;

            const hasNoBudget = !(budgetInfo.daysScheduled > 0);
            const daysBooked = (budgetInfo.daysBooked || 0).toFixed(2);
            const daysScheduled = (budgetInfo.daysScheduled || 0).toFixed(2);
            const scheduledText = hasNoBudget ? 'no budget' : `Scheduled: ${daysScheduled}d`;

            // Create text container
            const textContainer = document.createElement('span');
            textContainer.style.cssText = 'display: inline-flex; align-items: center; gap: 8px;';

            // Label: "ClientName (ProjectName):", with the project name replaced by a
            // dropdown switcher when the client has more than one project.
            const label = document.createElement('strong');
            label.style.marginRight = '4px';
            label.appendChild(document.createTextNode(`${budgetInfo.clientName} (`));

            if (clientBudgets.length > 1) {
                // A native 
                // over the top of it, so the real dropdown still opens on click/tap
                // and stays fully keyboard- and screen-reader-accessible.
                const switcher = document.createElement('span');
                switcher.style.cssText = 'position: relative; display: inline-flex; align-items: baseline; cursor: pointer;';

                const switcherText = document.createElement('span');
                switcherText.textContent = budgetInfo.projectName;
                switcher.appendChild(switcherText);

                const switcherArrow = document.createElement('span');
                switcherArrow.textContent = ' ▾';
                switcherArrow.style.fontSize = '8px';
                switcher.appendChild(switcherArrow);

                const projectSelect = document.createElement('select');
                projectSelect.setAttribute('aria-label', 'Select Olympus project');
                projectSelect.style.cssText = `
                    position: absolute;
                    inset: 0;
                    width: 100%;
                    height: 100%;
                    opacity: 0;
                    border: none;
                    padding: 0;
                    margin: 0;
                    cursor: pointer;
                `;
                for (const clientBudget of clientBudgets) {
                    const option = document.createElement('option');
                    option.value = clientBudget.projectName;
                    option.textContent = clientBudget.projectName;
                    option.selected = clientBudget.projectName === budgetInfo.projectName;
                    projectSelect.appendChild(option);
                }
                projectSelect.addEventListener('change', () => {
                    console.debug(`${LOG_PREFIX_ZENDESK} Project selection changed to "${projectSelect.value}" for ticket ${ticketId}`);
                    setProjectOverride(ticketId, projectSelect.value);
                    checkCurrentTicket();
                });
                switcher.appendChild(projectSelect);
                label.appendChild(switcher);
            } else {
                label.appendChild(document.createTextNode(budgetInfo.projectName));
            }

            label.appendChild(document.createTextNode('):'));
            textContainer.appendChild(label);

            const bookedSpan = document.createElement('span');
            bookedSpan.style.marginRight = '8px';
            bookedSpan.textContent = `Booked: ${daysBooked}d`;
            textContainer.appendChild(bookedSpan);

            const scheduledSpan = document.createElement('span');
            scheduledSpan.textContent = scheduledText;
            textContainer.appendChild(scheduledSpan);

            budgetSpan.appendChild(textContainer);

            // Calculate percentages for bar charts
            const budgetPercentage = getBudgetProgressPercentage(budgetInfo.daysBooked || 0, budgetInfo.daysScheduled || 0);
            const monthPercentage = getMonthProgressPercentage();
            const budgetBarColor = getBudgetBarColor(budgetPercentage, monthPercentage);

            // Create bar chart container
            const chartContainer = document.createElement('div');
            chartContainer.style.cssText = `
                display: flex;
                flex-direction: column;
                gap: 1px;
                margin-left: 4px;
            `;

            // Add top bar (budget progress) with label
            const topBarRow = document.createElement('div');
            topBarRow.style.cssText = 'display: flex; align-items: center; gap: 4px; line-height: 1;';

            const budgetLabel = document.createElement('span');
            budgetLabel.textContent = 'Budget';
            budgetLabel.style.cssText = 'font-size: 10px; color: #6b7280; min-width: 38px; line-height: 1;';

            const topBar = createProgressBar(budgetPercentage, budgetBarColor);
            topBarRow.appendChild(budgetLabel);
            topBarRow.appendChild(topBar);
            chartContainer.appendChild(topBarRow);

            // Add bottom bar (month progress) with label
            const bottomBarRow = document.createElement('div');
            bottomBarRow.style.cssText = 'display: flex; align-items: center; gap: 4px; line-height: 1;';

            const monthLabel = document.createElement('span');
            monthLabel.textContent = 'Month';
            monthLabel.style.cssText = 'font-size: 10px; color: #6b7280; min-width: 38px; line-height: 1;';

            const bottomBar = createProgressBar(monthPercentage, '#3b82f6');
            bottomBarRow.appendChild(monthLabel);
            bottomBarRow.appendChild(bottomBar);
            chartContainer.appendChild(bottomBarRow);

            budgetSpan.appendChild(chartContainer);

            // Find the last child of the nav bar and insert after it
            navBar.appendChild(budgetSpan);

            console.log(`${LOG_PREFIX_ZENDESK} ✅ Displayed budget for ${budgetInfo.clientName} (${budgetInfo.projectName})`, {
                budgetPercentage: hasNoBudget ? 'no budget' : budgetPercentage.toFixed(1) + '%',
                monthPercentage: monthPercentage.toFixed(1) + '%',
                budgetBarColor
            });
        }

        /**
         * Remove the budget display span if it exists
         */
        function removeBudgetDisplay() {
            const budgetSpan = document.getElementById(BUDGET_SPAN_ID);
            if (budgetSpan) {
                budgetSpan.remove();
            }
        }

        /**
         * Check the current ticket and update budget display
         */
        async function checkCurrentTicket() {
            console.debug(`${LOG_PREFIX_ZENDESK} checkCurrentTicket called`);
            const orgName = getOrganizationName();
            console.debug(`${LOG_PREFIX_ZENDESK} Organization name: ${orgName}`);

            if (!orgName) {
                console.debug(`${LOG_PREFIX_ZENDESK} No organization name found`);
                removeBudgetDisplay();
                return;
            }

            const budgetData = await getBudgetData();

            if (!budgetData) {
                console.debug(`${LOG_PREFIX_ZENDESK} No budget data available in storage`);
                removeBudgetDisplay();
                return;
            }

            const clientBudgets = findClientBudgets(budgetData, orgName);
            console.debug(`${LOG_PREFIX_ZENDESK} Client budget matches:`, clientBudgets);

            if (clientBudgets.length === 0) {
                console.debug(`${LOG_PREFIX_ZENDESK} No budget found for organisation "${orgName}"`);
                removeBudgetDisplay();
                return;
            }

            const ticketId = getTicketId();
            const overrideProjectName = getProjectOverride(ticketId);
            const overriddenBudget = clientBudgets.find(budget => budget.projectName === overrideProjectName);
            const selectedBudget = overriddenBudget || pickDefaultProjectBudget(clientBudgets);

            displayBudgetInfo(selectedBudget, clientBudgets, ticketId);
        }

        /**
         * Track URL changes for navigation detection
         */
        let lastUrl = window.location.href;
        const urlCheckInterval = setInterval(() => {
            const currentUrl = window.location.href;
            if (currentUrl !== lastUrl) {
                console.debug(`${LOG_PREFIX_ZENDESK} URL changed to ${currentUrl}`);
                lastUrl = currentUrl;

                // Wait a bit for the page to load
                setTimeout(() => {
                    checkCurrentTicket();
                }, 500);
            }
        }, CHECK_INTERVAL);

        /**
         * Watch for DOM changes to detect ticket updates
         */
        const observer = new MutationObserver((mutations) => {
            let shouldCheck = false;
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === 1) { // Element node
                            // Check if organization tab or header elements were added
                            if (node.matches && node.matches('[data-test-id="tabs-nav-item-organizations"]')) {
                                shouldCheck = true;
                                break;
                            }
                            if (node.querySelector && node.querySelector('[data-test-id="tabs-nav-item-organizations"]')) {
                                shouldCheck = true;
                                break;
                            }
                        }
                    }
                }
            }

            if (shouldCheck) {
                console.debug(`${LOG_PREFIX_ZENDESK} Relevant change detected, checking current ticket...`);
                checkCurrentTicket();
            }
        });

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

        // Initial check
        setTimeout(() => {
            checkCurrentTicket();
        }, 1000);

        console.log(`${LOG_PREFIX_ZENDESK} 🎉 Loaded and monitoring for ticket changes`);
    }

    // ====================================================================
    // DASHBOARD: Serve stored budget data to the page
    // ====================================================================
    // The dashboard page cannot read Tampermonkey's per-script storage itself, so
    // this branch renders nothing - it only answers the page's request for data.
    if (isDashboard) {
        const MESSAGE_SOURCE_PAGE = 'olympus-budget-dashboard';
        const MESSAGE_SOURCE_SCRIPT = 'olympus-budget-sync';
        const MESSAGE_TYPE_REQUEST = 'budget-data-request';
        const MESSAGE_TYPE_RESPONSE = 'budget-data';

        // postMessage rejects the origin a file:// page reports for itself (Chromium
        // says "file://" while the recipient's real origin is "null"), so fall back to
        // a wildcard for anything that is not a normal web origin.
        // '*' only ever applies to file:// test fixtures; real pages use their own origin.
        const pageOrigin = window.location.origin;
        const TARGET_ORIGIN = pageOrigin && pageOrigin.startsWith('http') ? pageOrigin : '*';

        /**
         * Read the stored budget payload and capture time
         * @returns {Promise<{projects: Array|null, capturedAt: number|null}>} The stored data,
         *          with projects null when nothing has been captured yet
         */
        async function readStoredBudgetData() {
            try {
                console.debug(`${LOG_PREFIX_DASHBOARD} Reading budget data from storage...`);
                const stored = await GM.getValue(STORAGE_KEY);
                const storedCapturedAt = await GM.getValue(STORAGE_KEY_CAPTURED_AT);

                const parsed = stored ? JSON.parse(stored) : null;
                const projects = Array.isArray(parsed) ? parsed : null;

                const capturedAtNumber = Number(storedCapturedAt);
                const capturedAt = Number.isFinite(capturedAtNumber) && capturedAtNumber > 0
                    ? capturedAtNumber
                    : null;

                console.debug(`${LOG_PREFIX_DASHBOARD} Read ${projects ? projects.length : 0} entries from storage`, {
                    capturedAt
                });
                return { projects, capturedAt };
            } catch (e) {
                console.log(`${LOG_PREFIX_DASHBOARD} ❌ Error reading stored data:`, e);
                return { projects: null, capturedAt: null };
            }
        }

        /**
         * Post the stored budget data to the page
         * @param {string} reason - Why the data is being sent, for logging
         */
        async function sendBudgetData(reason) {
            const { projects, capturedAt } = await readStoredBudgetData();
            const message = {
                source: MESSAGE_SOURCE_SCRIPT,
                type: MESSAGE_TYPE_RESPONSE,
                version: SCRIPT_VERSION,
                projects,
                capturedAt
            };

            console.debug(`${LOG_PREFIX_DASHBOARD} Posting budget data (${reason}) to ${TARGET_ORIGIN}`, message);
            win.postMessage(message, TARGET_ORIGIN);
            console.log(`${LOG_PREFIX_DASHBOARD} ✅ Sent ${projects ? projects.length : 0} project budgets to the dashboard (${reason})`);
        }

        /**
         * Determine whether a message event is a genuine request from the dashboard page
         * @param {MessageEvent} event - The incoming message event
         * @returns {boolean} True if the event is a valid budget data request
         */
        function isBudgetDataRequest(event) {
            if (event.source !== win) return false;
            // 'null' is what a file:// test fixture reports as its origin on receipt.
            if (event.origin !== pageOrigin && event.origin !== 'null') return false;
            const data = event.data;
            return !!data && data.source === MESSAGE_SOURCE_PAGE && data.type === MESSAGE_TYPE_REQUEST;
        }

        win.addEventListener('message', (event) => {
            if (!isBudgetDataRequest(event)) return;
            console.debug(`${LOG_PREFIX_DASHBOARD} Received a budget data request from the page`);
            sendBudgetData('page request');
        });

        // Post once on init as well, in case the page registered its listener first
        // and never needs to ask. Together these make the handshake race-free.
        sendBudgetData('init');

        console.debug(`${LOG_PREFIX_DASHBOARD} Listening for budget data requests`);
    }
})();