Olympus Timesheet Role Autofill v1.0.0
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();
})();