Olympus-Zendesk Budget Sync v1.2.0

← Back to User Scripts

A unified script that syncs Olympus project budget data and displays it in Zendesk ticket headers using cross-domain storage via the GM.setValue/GM.getValue API. Includes visual bar charts showing budget spend and month progress.

Script Content

// ==UserScript==
// @name         Olympus-Zendesk: Budget Sync
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.2.0
// @description  Syncs Olympus project budget data to Zendesk ticket headers across sites
// @author       Tim Hilton using GitHub Copilot
// @match        https://olympus.audacia.co.uk/*
// @match        https://*.zendesk.com/agent/tickets/*
// @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 STORAGE_KEY = 'olympusClientBudgets';
    const isOlympus = window.location.hostname === 'olympus.audacia.co.uk';
    const isZendesk = window.location.hostname.endsWith('.zendesk.com');
    
    const LOG_PREFIX_OLYMPUS = '[Olympus-Zendesk: Budget Sync (Olympus)]';
    const LOG_PREFIX_ZENDESK = '[Olympus-Zendesk: Budget Sync (Zendesk)]';

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

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

        /**
         * 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 || '',
                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));
                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) {
            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;
                }
            },
            help: function() {
                console.log(`
Budget Sync - Console Commands:

  olympusBudgetSync.view()  - View the currently stored budget data
  olympusBudgetSync.help()  - Show this help message

This script automatically intercepts GET requests to ${API_URL}
and stores client budget information for use in Zendesk.
                `);
            }
        };
        
        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
        
        // 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 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 budget info for a specific client (case-insensitive match)
         * @param {Array} budgetData - Array of budget objects
         * @param {string} clientName - Client name to search for
         * @returns {Object|null} Budget object or null if not found
         */
        function findClientBudget(budgetData, clientName) {
            if (!Array.isArray(budgetData) || !clientName) {
                return null;
            }

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

        /**
         * Get the number of days in the current month
         * @returns {number} Number of days in the current month
         */
        function getDaysInCurrentMonth() {
            const now = new Date();
            const year = now.getFullYear();
            const month = now.getMonth();
            // Get the last day of the month (day 0 of next month)
            return new Date(year, month + 1, 0).getDate();
        }

        /**
         * Get the current day of the month
         * @returns {number} Current day of the month (1-31)
         */
        function getCurrentDayOfMonth() {
            return new Date().getDate();
        }

        /**
         * Calculate month progress as a percentage
         * @returns {number} Percentage of month completed (0-100)
         */
        function getMonthProgressPercentage() {
            const currentDay = getCurrentDayOfMonth();
            const daysInMonth = getDaysInCurrentMonth();
            return (currentDay / daysInMonth) * 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)
         */
        function getBudgetProgressPercentage(booked, scheduled) {
            if (scheduled === 0) return 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%
            if (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
        }

        /**
         * Create a horizontal progress bar
         * @param {number} percentage - Progress percentage (0-100+)
         * @param {string} fillColor - Color for the fill bar
         * @returns {HTMLElement} The progress bar container element
         */
        function createProgressBar(percentage, fillColor) {
            const isOverLimit = percentage > 100;
            const extensionPercentage = isOverLimit ? Math.min(percentage - 100, MAX_EXTENSION_PERCENTAGE) : 0;
            
            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 = Math.min(percentage, 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}%;
                    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 object
         */
        function displayBudgetInfo(budgetInfo) {
            // 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 daysBooked = (budgetInfo.daysBooked || 0).toFixed(2);
            const daysScheduled = (budgetInfo.daysScheduled || 0).toFixed(2);
            
            // Create text container
            const textContainer = document.createElement('span');
            textContainer.style.cssText = 'display: inline-flex; align-items: center; gap: 8px;';
            textContainer.innerHTML = `
                ${budgetInfo.clientName}:
                Booked: ${daysBooked}d
                Scheduled: ${daysScheduled}d
            `;
            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}`, {
                budgetPercentage: 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 clientBudget = findClientBudget(budgetData, orgName);
            console.debug(`${LOG_PREFIX_ZENDESK} Client budget match:`, clientBudget);
            
            if (clientBudget) {
                displayBudgetInfo(clientBudget);
            } else {
                console.debug(`${LOG_PREFIX_ZENDESK} No budget found for organisation "${orgName}"`);
                removeBudgetDisplay();
            }
        }

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