Olympus-Zendesk Budget Sync v1.4.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 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.
Month progress is now measured in working days (Monday to Friday) rather than calendar days, on both the dashboard and the Zendesk bar chart. Clients with time booked against no budget at all are shown as over budget instead of at zero.
Script Content
// ==UserScript==
// @name Olympus-Zendesk: Budget Sync
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.4.0
// @description Syncs Olympus project budget data to Zendesk ticket headers and a dashboard on timhilton.xyz
// @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 = '1.4.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
// 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
);
}
/**
* 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 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 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;';
textContainer.innerHTML = `
${budgetInfo.clientName}:
Booked: ${daysBooked}d
${scheduledText}
`;
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: hasNoBudget ? 'no budget' : 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`);
}
// ====================================================================
// DASHBOARD: Serve stored budget data to the page
// ====================================================================
// The dashboard page cannot read Tampermonkey's per-script storage itself, so
// this branch renders nothing - it only answers the page's request for data.
if (isDashboard) {
const MESSAGE_SOURCE_PAGE = 'olympus-budget-dashboard';
const MESSAGE_SOURCE_SCRIPT = 'olympus-budget-sync';
const MESSAGE_TYPE_REQUEST = 'budget-data-request';
const MESSAGE_TYPE_RESPONSE = 'budget-data';
// postMessage rejects the origin a file:// page reports for itself (Chromium
// says "file://" while the recipient's real origin is "null"), so fall back to
// a wildcard for anything that is not a normal web origin.
// '*' only ever applies to file:// test fixtures; real pages use their own origin.
const pageOrigin = window.location.origin;
const TARGET_ORIGIN = pageOrigin && pageOrigin.startsWith('http') ? pageOrigin : '*';
/**
* Read the stored budget payload and capture time
* @returns {Promise<{projects: Array|null, capturedAt: number|null}>} The stored data,
* with projects null when nothing has been captured yet
*/
async function readStoredBudgetData() {
try {
console.debug(`${LOG_PREFIX_DASHBOARD} Reading budget data from storage...`);
const stored = await GM.getValue(STORAGE_KEY);
const storedCapturedAt = await GM.getValue(STORAGE_KEY_CAPTURED_AT);
const parsed = stored ? JSON.parse(stored) : null;
const projects = Array.isArray(parsed) ? parsed : null;
const capturedAtNumber = Number(storedCapturedAt);
const capturedAt = Number.isFinite(capturedAtNumber) && capturedAtNumber > 0
? capturedAtNumber
: null;
console.debug(`${LOG_PREFIX_DASHBOARD} Read ${projects ? projects.length : 0} entries from storage`, {
capturedAt
});
return { projects, capturedAt };
} catch (e) {
console.log(`${LOG_PREFIX_DASHBOARD} ❌ Error reading stored data:`, e);
return { projects: null, capturedAt: null };
}
}
/**
* Post the stored budget data to the page
* @param {string} reason - Why the data is being sent, for logging
*/
async function sendBudgetData(reason) {
const { projects, capturedAt } = await readStoredBudgetData();
const message = {
source: MESSAGE_SOURCE_SCRIPT,
type: MESSAGE_TYPE_RESPONSE,
version: SCRIPT_VERSION,
projects,
capturedAt
};
console.debug(`${LOG_PREFIX_DASHBOARD} Posting budget data (${reason}) to ${TARGET_ORIGIN}`, message);
win.postMessage(message, TARGET_ORIGIN);
console.log(`${LOG_PREFIX_DASHBOARD} ✅ Sent ${projects ? projects.length : 0} project budgets to the dashboard (${reason})`);
}
/**
* Determine whether a message event is a genuine request from the dashboard page
* @param {MessageEvent} event - The incoming message event
* @returns {boolean} True if the event is a valid budget data request
*/
function isBudgetDataRequest(event) {
if (event.source !== win) return false;
// 'null' is what a file:// test fixture reports as its origin on receipt.
if (event.origin !== pageOrigin && event.origin !== 'null') return false;
const data = event.data;
return !!data && data.source === MESSAGE_SOURCE_PAGE && data.type === MESSAGE_TYPE_REQUEST;
}
win.addEventListener('message', (event) => {
if (!isBudgetDataRequest(event)) return;
console.debug(`${LOG_PREFIX_DASHBOARD} Received a budget data request from the page`);
sendBudgetData('page request');
});
// Post once on init as well, in case the page registered its listener first
// and never needs to ask. Together these make the handshake race-free.
sendBudgetData('init');
console.debug(`${LOG_PREFIX_DASHBOARD} Listening for budget data requests`);
}
})();