Olympus-Zendesk Budget Sync v1.0.0
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.
Script Content
// ==UserScript==
// @name Olympus-Zendesk: Budget Sync
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.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
/**
* 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
);
}
/**
* 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: 4px 8px;
background-color: #f0f4ff;
border: 1px solid #c7d7fe;
border-radius: 4px;
font-size: 12px;
color: #1e40af;
white-space: nowrap;
`;
const daysBooked = budgetInfo.daysBooked || 0;
const daysScheduled = budgetInfo.daysScheduled || 0;
budgetSpan.innerHTML = `
${budgetInfo.clientName}:
Booked: ${daysBooked}d
Scheduled: ${daysScheduled}d
`;
// 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}`);
}
/**
* 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`);
}
})();