Olympus-Zendesk Budget Sync v2.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. Includes visual bar charts showing budget spend and month progress.
This version fixes clients with multiple projects showing the wrong one in Zendesk. The badge now always shows the project name in brackets after the client name (e.g. Foo (Bar):), defaults to the project whose name contains "support" nearest the start of its name, and — when a client has more than one project — lets you switch project from a dropdown built into the badge. A manual choice is remembered per ticket, so it only affects that ticket.
This version also powers the Olympus Budget Dashboard on this site. Because Tampermonkey's storage cannot be read by ordinary page JavaScript, the dashboard asks the script for the captured data and the script posts it back to the page — so the dashboard only works when this script is installed.
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