Olympus Timesheet Sum v1.0.0
Adds a colour-coded sum badge to the timesheet tab bar showing total hours for the week, green when averaging at least 7.5 h/day and red otherwise.
Script Content
// ==UserScript==
// @name Olympus: Timesheet Sum
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Shows the sum of timesheet hours for the week with a colour-coded average badge
// @author Tim Hilton using GitHub Copilot
// @match https://olympus.audacia.co.uk/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Olympus: Timesheet Sum]';
const BADGE_ID = 'timesheet-sum-badge';
const CONTAINER_SELECTOR = '[data-test="view-timesheets-tab__input-form__timesheet-date-hours"]';
const TABS_WRAPPER_SELECTOR = '.mud-tabs-tabbar-wrapper.mud-tabs-centered';
const TAB_SELECTOR = '.mud-tab';
const BADGE_SELECTOR = '.mud-badge';
const HOURS_PER_DAY = 7.5;
console.log(`${LOG_PREFIX} Script initialised`);
/**
* Get today's date with time set to midnight for comparison.
*/
function getToday() {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
}
/**
* Map of lowercase month names / abbreviations to 0-based month index.
*/
const MONTH_MAP = {
jan: 0, january: 0,
feb: 1, february: 1,
mar: 2, march: 2,
apr: 3, april: 3,
may: 4,
jun: 5, june: 5,
jul: 6, july: 6,
aug: 7, august: 7,
sep: 8, september: 8,
oct: 9, october: 9,
nov: 10, november: 10,
dec: 11, december: 11,
};
/**
* Build a Date at midnight for the given day and 0-based month, adjusting
* the year backwards if the resulting date is more than ~180 days away.
*/
function buildDate(day, month) {
const now = new Date();
const date = new Date(now.getFullYear(), month, day, 0, 0, 0, 0);
const msInHalfYear = 180 * 24 * 60 * 60 * 1000;
if (date.getTime() - now.getTime() > msInHalfYear) {
date.setFullYear(now.getFullYear() - 1);
}
console.debug(`${LOG_PREFIX} Parsed date: ${date.toDateString()}`);
return date;
}
/**
* Extract the 0-based month index from the "Daily Timesheets (MonthName)"
* heading on the page. Searches for the text pattern in the timesheet
* card area first, then falls back to anywhere on the page.
* Returns null if the heading cannot be found.
*/
function getMonthFromHeading() {
const container = document.querySelector(CONTAINER_SELECTOR);
const searchRoot = container
? (container.closest('.mud-card') || container.parentElement)
: document.body;
const text = searchRoot.textContent || '';
const match = text.match(/Daily\s+Timesheets\s*\(([A-Za-z]+)\)/i);
if (match) {
const monthKey = match[1].toLowerCase();
const month = MONTH_MAP[monthKey];
if (month !== undefined) {
console.debug(`${LOG_PREFIX} Month from heading: ${match[1]} (${month})`);
return month;
}
}
return null;
}
/**
* Try to parse a date from a tab element's text content.
* Supports:
* - Ordinal format: "Mon, 16th" / "Tue, 1st" (month from page heading)
* - dd/MM or d/M (e.g. "24/03", "5/3")
* - d MMM or dd MMM / full month name (e.g. "23 Mar", "5 March")
* The badge value inside the tab is part of the textContent but does not
* contain a slash or a month name so it does not interfere.
*/
function parseDateFromTab(tabElement, headingMonth) {
const text = tabElement.textContent || '';
const trimmed = text.trim();
console.debug(`${LOG_PREFIX} Parsing date from tab text: "${trimmed}"`);
// Format 1: ordinal day (e.g. "Mon, 16th7.75" or "Tue, 1st0")
const ordinalMatch = trimmed.match(/(\d{1,2})(?:st|nd|rd|th)/i);
if (ordinalMatch) {
const day = parseInt(ordinalMatch[1], 10);
if (headingMonth !== null) {
return buildDate(day, headingMonth);
}
// Fallback: use current month if heading not available — may be
// wrong when viewing a different month's timesheets.
const now = new Date();
console.debug(`${LOG_PREFIX} ⚠️ Heading month unavailable; falling back to current month`);
return buildDate(day, now.getMonth());
}
// Format 2: dd/MM or d/M (e.g. "24/03" or "24/3")
const slashMatch = trimmed.match(/(\d{1,2})\/(\d{1,2})/);
if (slashMatch) {
const day = parseInt(slashMatch[1], 10);
const month = parseInt(slashMatch[2], 10) - 1;
return buildDate(day, month);
}
// Format 3: d MMM or dd MMM / full month name (e.g. "23 Mar" or "5 March")
const monthNameMatch = trimmed.match(/(\d{1,2})\s+([A-Za-z]{3,9})/);
if (monthNameMatch) {
const day = parseInt(monthNameMatch[1], 10);
const monthKey = monthNameMatch[2].toLowerCase();
const month = MONTH_MAP[monthKey];
if (month !== undefined) {
return buildDate(day, month);
}
}
console.debug(`${LOG_PREFIX} Could not parse date from tab text: "${trimmed}"`);
return null;
}
/**
* Return the hours value shown in the badge of a tab element, or 0 if absent.
*/
function getHoursFromTab(tabElement) {
const badge = tabElement.querySelector(BADGE_SELECTOR);
if (!badge) {
console.debug(`${LOG_PREFIX} No badge found in tab`);
return 0;
}
const value = parseFloat(badge.textContent);
return isNaN(value) ? 0 : value;
}
/**
* Format the hours total for display, rounded to the nearest 0.25 and
* stripping unnecessary trailing zeros.
* Examples: 15.0 → "15", 22.5 → "22.5", 38.26 → "38.25", 22.13 → "22.25"
*/
function formatHours(hours) {
const rounded = Math.round(hours * 4) / 4;
if (rounded % 1 === 0) return rounded.toFixed(0);
if (rounded % 0.5 === 0) return rounded.toFixed(1);
return rounded.toFixed(2);
}
/**
* Build and return the DOM element for the sum badge.
*/
function createSumBadge(totalHours, isGreen) {
const badge = document.createElement('div');
badge.id = BADGE_ID;
badge.style.cssText = [
'display: inline-flex',
'align-items: center',
'justify-content: center',
'padding: 2px 10px',
'border-radius: 12px',
'font-size: 12px',
'font-weight: 600',
'color: white',
`background-color: ${isGreen ? '#4caf50' : '#f44336'}`,
'margin: auto 8px',
'white-space: nowrap',
'line-height: 1.4',
'flex-shrink: 0',
].join('; ');
badge.textContent = `${formatHours(totalHours)}h`;
return badge;
}
/**
* Read the tabs inside the timesheet container and re-render the sum badge.
*/
function updateSumBadge() {
console.debug(`${LOG_PREFIX} Updating sum badge`);
const container = document.querySelector(CONTAINER_SELECTOR);
if (!container) {
console.debug(`${LOG_PREFIX} Timesheet container not found`);
return;
}
const tabsWrapper = container.querySelector(TABS_WRAPPER_SELECTOR);
if (!tabsWrapper) {
console.debug(`${LOG_PREFIX} Tabs wrapper not found`);
return;
}
const tabs = tabsWrapper.querySelectorAll(TAB_SELECTOR);
if (!tabs.length) {
console.debug(`${LOG_PREFIX} No tabs found`);
return;
}
const today = getToday();
const headingMonth = getMonthFromHeading();
let totalHours = 0;
let dayCount = 0;
tabs.forEach((tab, index) => {
const hours = getHoursFromTab(tab);
const date = parseDateFromTab(tab, headingMonth);
if (!date) {
console.debug(`${LOG_PREFIX} Tab ${index + 1}: no date parsed, skipping`);
return;
}
if (date > today) {
console.debug(`${LOG_PREFIX} Tab ${index + 1}: ${date.toDateString()} – skipping (future date)`);
return;
}
if (date.getTime() === today.getTime() && hours === 0) {
console.debug(`${LOG_PREFIX} Tab ${index + 1}: ${date.toDateString()} – skipping (today with 0 hours)`);
return;
}
console.debug(`${LOG_PREFIX} Tab ${index + 1}: ${date.toDateString()} = ${hours}h (included)`);
totalHours += hours;
dayCount++;
});
// Remove any existing badge before re-inserting
const existingBadge = document.getElementById(BADGE_ID);
if (existingBadge) {
existingBadge.remove();
}
if (dayCount === 0) {
console.debug(`${LOG_PREFIX} No days to sum; badge not shown`);
return;
}
const average = totalHours / dayCount;
const isGreen = average >= HOURS_PER_DAY;
console.debug(`${LOG_PREFIX} Sum: ${totalHours}h over ${dayCount} day(s) (avg ${average.toFixed(2)}h/day)`);
const badge = createSumBadge(totalHours, isGreen);
tabsWrapper.insertBefore(badge, tabsWrapper.firstChild);
console.log(`${LOG_PREFIX} ${isGreen ? '✅' : '❌'} Badge updated: ${formatHours(totalHours)}h (avg ${average.toFixed(2)}h/day)`);
}
// ─── Observer wiring ────────────────────────────────────────────────────────
let containerObserver = null;
let updateTimeout = null;
let currentContainer = null;
/**
* Start watching a specific container element for DOM mutations so the badge
* is refreshed whenever hours change. Disconnects if the container is
* removed from the page (e.g. SPA navigation).
*/
function observeContainer(container) {
if (containerObserver) {
containerObserver.disconnect();
}
containerObserver = new MutationObserver(() => {
console.debug(`${LOG_PREFIX} Container mutation observed`);
if (!document.contains(container)) {
console.debug(`${LOG_PREFIX} Container removed; watching body for re-appearance`);
containerObserver.disconnect();
currentContainer = null;
watchForContainer();
return;
}
clearTimeout(updateTimeout);
updateTimeout = setTimeout(updateSumBadge, 300);
});
containerObserver.observe(container, { childList: true, subtree: true, characterData: true });
console.debug(`${LOG_PREFIX} Observing container`);
}
/**
* Watch document.body (childList + subtree) until the timesheet container
* appears, then hand off to observeContainer() and disconnect the body
* observer to keep overhead minimal.
*/
function watchForContainer() {
const bodyObserver = new MutationObserver(() => {
const container = document.querySelector(CONTAINER_SELECTOR);
if (container && container !== currentContainer) {
bodyObserver.disconnect();
currentContainer = container;
observeContainer(container);
updateSumBadge();
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
console.debug(`${LOG_PREFIX} Watching body for timesheet container`);
}
// Kick off: if the container is already in the DOM use it immediately,
// otherwise wait for it to appear via SPA navigation.
const initialContainer = document.querySelector(CONTAINER_SELECTOR);
if (initialContainer) {
currentContainer = initialContainer;
observeContainer(initialContainer);
updateSumBadge();
} else {
watchForContainer();
}
console.log(`${LOG_PREFIX} Ready`);
})();