// ==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