Olympus Timesheet Role Autofill v1.0.0

← Back to User Scripts

Adds an "Auto-select" button to the Role column of the Import from Toggl modal which fills empty roles using an ordered list of saved keywords.

Script Content

// ==UserScript==
// @name         Olympus: Timesheet Role Autofill
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Auto-selects empty roles in the Import from Toggl modal using an ordered list of keywords
// @author       Tim Hilton using Claude
// @match        https://olympus.audacia.co.uk/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Olympus: Timesheet Role Autofill]';
    const STORAGE_KEY = 'olympus-timesheet-role-autofill-keywords';
    const CONTROLS_ID = 'olympus-role-autofill';
    const DIALOG_ID = 'olympus-role-autofill-keywords';
    const TABLE_SELECTOR = '[data-test="toggl-modal__entries-table"]';
    const ROLE_INPUT_SELECTOR = '[data-test="toggl-modal__entries-table__role-input-dropdown"]';

    console.debug(`${LOG_PREFIX} Script initialised`);

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    async function waitFor(predicate, timeoutMs = 3000) {
        for (let waited = 0; waited < timeoutMs; waited += 50) {
            const result = predicate();
            if (result) return result;
            await sleep(50);
        }
        return null;
    }

    function loadKeywords() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
        } catch {
            return [];
        }
    }

    function saveKeywords(keywords) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(keywords));
        console.debug(`${LOG_PREFIX} Saved keywords: ${keywords.join(', ')}`);
    }

    /**
     * Returns the first option containing the highest-precedence keyword.
     * Rows are independent, so checking each row against the keywords in order
     * gives the same result as checking every row against each keyword in turn.
     */
    function pickRole(options, keywords) {
        for (const keyword of keywords) {
            const option = options.find((o) => o.textContent.toLowerCase().includes(keyword.toLowerCase()));
            if (option) return { option, keyword };
        }
        return null;
    }

    /**
     * Opens a row's role autocomplete, picks a matching option (or closes it again) and
     * waits for the popover to close. MudBlazor renders the options in a popover elsewhere
     * in the DOM with ids of the form `_item`, and removes them on close.
     */
    async function fillRole(input, keywords) {
        const autocomplete = input.closest('.mud-autocomplete');
        const selectId = autocomplete.id;
        const firstOption = () => document.getElementById(`${selectId}_item0`);
        // The arrow button reliably toggles the popover; a synthetic Escape or blur does not close it
        const toggle = autocomplete.querySelector('.mud-input-adornment button');

        if (!firstOption()) toggle.click();
        if (!(await waitFor(firstOption))) {
            console.debug(`${LOG_PREFIX} No role options appeared for ${selectId}`);
            return null;
        }

        const options = [...document.querySelectorAll(`[id^="${selectId}_item"]`)];
        console.debug(`${LOG_PREFIX} ${selectId} options: ${options.map((o) => o.textContent.trim()).join(' | ')}`);
        const picked = pickRole(options, keywords);
        if (picked) {
            picked.option.click();
        } else {
            toggle.click();
        }
        await waitFor(() => !firstOption());
        return picked;
    }

    async function autoSelectRoles(status) {
        const keywords = loadKeywords();
        if (!keywords.length) {
            status.textContent = 'No keywords set';
            console.log(`${LOG_PREFIX} ⏭️ No keywords configured`);
            return;
        }

        // Every row is in the DOM (the table is not virtualised), so this covers rows scrolled out of view
        const emptyInputs = [...document.querySelectorAll(`${TABLE_SELECTOR} ${ROLE_INPUT_SELECTOR}`)].filter(
            (input) => !input.value,
        );
        console.debug(`${LOG_PREFIX} ${emptyInputs.length} row(s) without a role`);

        let filled = 0;
        for (const input of emptyInputs) {
            status.textContent = `${filled}/${emptyInputs.length}…`;
            const picked = await fillRole(input, keywords);
            if (picked) {
                filled++;
                console.debug(`${LOG_PREFIX} Selected "${picked.option.textContent.trim()}" via "${picked.keyword}"`);
            }
        }

        status.textContent = `Set ${filled}/${emptyInputs.length}`;
        console.log(`${LOG_PREFIX} ✅ Set ${filled} of ${emptyInputs.length} empty role(s)`);
    }

    function iconButton(text, label, disabled, onClick) {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'icon';
        button.textContent = text;
        button.title = label;
        button.setAttribute('aria-label', label);
        button.disabled = disabled;
        button.addEventListener('click', onClick);
        return button;
    }

    function renderKeywords(list) {
        const keywords = loadKeywords();
        const update = (change) => {
            const updated = loadKeywords();
            change(updated);
            saveKeywords(updated);
            renderKeywords(list);
        };
        list.replaceChildren(
            ...keywords.map((keyword, index) => {
                const item = document.createElement('li');
                const text = document.createElement('span');
                text.textContent = keyword;
                item.append(
                    text,
                    iconButton('↑', `Move ${keyword} up`, index === 0, () =>
                        update((k) => k.splice(index - 1, 0, ...k.splice(index, 1))),
                    ),
                    iconButton('↓', `Move ${keyword} down`, index === keywords.length - 1, () =>
                        update((k) => k.splice(index + 1, 0, ...k.splice(index, 1))),
                    ),
                    iconButton('×', `Remove ${keyword}`, false, () => update((k) => k.splice(index, 1))),
                );
                return item;
            }),
        );
    }

    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #${CONTROLS_ID}, #${DIALOG_ID} {
                font-size: 12px;

                & button {
                    cursor: pointer;
                    border: 1px solid currentColor;
                    border-radius: 4px;
                    padding: 0 6px;
                    min-width: 24px;
                    line-height: 20px;
                    background: none;
                    color: inherit;

                    &:disabled { opacity: 0.3; cursor: default; }
                }
                & .run {
                    border-color: transparent;
                    background: var(--mud-palette-primary, #594ae2);
                    color: var(--mud-palette-primary-text, #fff);
                }
            }
            #${CONTROLS_ID} {
                display: inline-flex;
                flex-wrap: wrap;
                gap: 4px;
                align-items: center;
                margin-left: 8px;
                font-weight: normal;

                & .status { opacity: 0.8; }
            }
            #${DIALOG_ID} {
                /* Olympus's CSS reset removes the default centring and padding */
                margin: auto;
                padding: 20px 24px;
                width: 360px;
                border: none;
                border-radius: 4px;
                background: var(--mud-palette-surface, #fff);
                color: var(--mud-palette-text-primary, #000);
                box-shadow: 0 4px 12px rgb(0 0 0 / 0.4);
                font-size: 13px;

                &::backdrop { background: rgb(0 0 0 / 0.4); }
                & h2 { margin: 0 0 8px; font-size: 16px; }
                & p { margin: 0 0 16px; opacity: 0.8; }
                & ol { margin: 0 0 12px; padding: 0; }
                & li { display: flex; gap: 4px; align-items: center; margin-bottom: 4px; }
                & li span { flex: 1; overflow-wrap: anywhere; }
                & .icon { width: 24px; padding: 0; }
                & .add { display: flex; gap: 4px; }
                & input { flex: 1; min-width: 0; color: inherit; background: none; border: 1px solid currentColor; border-radius: 4px; padding: 6px 8px; }
                & form { display: flex; justify-content: flex-end; margin-top: 16px; }
            }
        `;
        document.head.append(style);
    }

    /** A native modal dialog sits in the top layer, above the MudBlazor modal. */
    function createKeywordsDialog() {
        const dialog = document.createElement('dialog');
        dialog.id = DIALOG_ID;
        dialog.innerHTML = `
            

Role keywords

In order of precedence. Each empty role gets the first option containing the highest keyword that matches.

    `; const list = dialog.querySelector('ol'); const newKeyword = document.createElement('input'); newKeyword.type = 'text'; newKeyword.placeholder = 'Add keyword and press Return'; newKeyword.dataset.test = 'role-autofill-new-keyword'; newKeyword.autofocus = true; function addKeyword() { const keyword = newKeyword.value.trim(); if (!keyword) return; const keywords = loadKeywords(); if (!keywords.some((k) => k.toLowerCase() === keyword.toLowerCase())) { saveKeywords([...keywords, keyword]); renderKeywords(list); } newKeyword.value = ''; newKeyword.focus(); } newKeyword.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); addKeyword(); }); dialog.querySelector('.add').append(newKeyword, iconButton('↵', 'Add keyword', false, addKeyword)); // Stop Escape reaching MudBlazor, which would otherwise close the Import from Toggl modal underneath dialog.addEventListener('keydown', (e) => { if (e.key === 'Escape') e.stopPropagation(); }); document.body.append(dialog); return () => { renderKeywords(list); dialog.showModal(); }; } function createHeaderControls(openKeywords) { const controls = document.createElement('span'); controls.id = CONTROLS_ID; controls.innerHTML = ` `; const run = controls.querySelector('.run'); const status = controls.querySelector('.status'); run.addEventListener('click', async () => { run.disabled = true; try { await autoSelectRoles(status); } catch (error) { status.textContent = 'Failed - see console'; console.log(`${LOG_PREFIX} ❌ Auto-select failed`, error); } finally { run.disabled = false; } }); controls.querySelector('[data-test="role-autofill-edit"]').addEventListener('click', openKeywords); return controls; } addStyles(); const controls = createHeaderControls(createKeywordsDialog()); /** * The modal's DOM (and so the controls) is discarded each time it closes, so re-add the same * controls whenever the Role header reappears. The callback is cheap when nothing needs doing. */ function ensureHeaderControls() { if (document.getElementById(CONTROLS_ID)) return; const header = [...document.querySelectorAll(`${TABLE_SELECTOR} thead th`)].find( (th) => th.textContent.trim() === 'Role', ); if (!header) return; controls.querySelector('.status').textContent = ''; header.append(controls); console.debug(`${LOG_PREFIX} Controls added to Role header`); } new MutationObserver(ensureHeaderControls).observe(document.body, { childList: true, subtree: true }); ensureHeaderControls(); })();