Olympus-Zendesk Budget Sync v1.2.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. 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`);
}
})();