Olympus Timesheet Sum v1.0.0

← Back to User Scripts

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`);
})();