Created
June 17, 2026 00:08
-
-
Save jasoncodes/0e92de17f7fdbf992c0c1967849c245d to your computer and use it in GitHub Desktop.
Azure Boards userscript for Standups
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Azure Boards userscript for Standups | |
| // @namespace local.azure-devops-taskboard | |
| // @version 0.1.0 | |
| // @match https://dev.azure.com/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| // toggle to hide fixed headers on smaller screens | |
| (() => { | |
| 'use strict'; | |
| const HOST = 'dev.azure.com'; | |
| const PATH_PART = '/_sprints/taskboard/'; | |
| const ROOT_ID = 'ado-taskboard-header-toggle'; | |
| const STYLE_ID = 'ado-taskboard-header-toggle-style'; | |
| const HIDDEN_CLASS = 'ado-taskboard-headers-hidden'; | |
| const TARGET_SELECTORS = ['.project-header', '.bolt-header', '.hide-on-mobile']; | |
| if (location.hostname !== HOST || !location.pathname.includes(PATH_PART)) return; | |
| let headersHidden = false; | |
| localStorage.removeItem('adoTaskboardHeadersHidden'); | |
| const updateToggle = (value = headersHidden) => { | |
| const button = document.getElementById(ROOT_ID); | |
| if (!button) return; | |
| button.setAttribute('aria-pressed', String(value)); | |
| button.setAttribute('aria-label', value ? 'Show taskboard headers' : 'Hide taskboard headers'); | |
| button.title = value ? 'Show taskboard headers' : 'Hide taskboard headers'; | |
| button.classList.toggle('active', value); | |
| }; | |
| const setHidden = (value) => { | |
| headersHidden = value; | |
| document.documentElement.classList.toggle(HIDDEN_CLASS, value); | |
| updateToggle(value); | |
| }; | |
| const ensureStyle = () => { | |
| if (document.getElementById(STYLE_ID) || !document.head) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| const hiddenSelector = TARGET_SELECTORS | |
| .map((selector) => `html.${HIDDEN_CLASS} ${selector}`) | |
| .join(',\n '); | |
| style.textContent = ` | |
| ${hiddenSelector} { | |
| display: none !important; | |
| } | |
| #${ROOT_ID} { | |
| align-items: center; | |
| background: transparent; | |
| border: 0; | |
| color: inherit; | |
| cursor: pointer; | |
| display: flex; | |
| flex-shrink: 0; | |
| font: inherit; | |
| height: 40px; | |
| justify-content: center; | |
| margin: 0; | |
| padding: 0; | |
| text-align: left; | |
| width: 100%; | |
| } | |
| #${ROOT_ID}:hover, | |
| #${ROOT_ID}:focus-visible, | |
| #${ROOT_ID}.active { | |
| background: rgba(0, 90, 158, 0.12); | |
| } | |
| #${ROOT_ID}:focus-visible { | |
| outline: 1px solid currentColor; | |
| outline-offset: -3px; | |
| } | |
| #${ROOT_ID}.active .navigation-icon { | |
| color: rgb(0, 120, 212); | |
| } | |
| `; | |
| document.head.append(style); | |
| }; | |
| const buildToggle = () => { | |
| const container = document.createElement('div'); | |
| container.id = `${ROOT_ID}-container`; | |
| container.className = 'hub-group-container flex-column flex-noshrink relative hub-group-only'; | |
| const button = document.createElement('button'); | |
| button.id = ROOT_ID; | |
| button.type = 'button'; | |
| button.className = 'hub-group navigation-element navigation-link focus-treatment flex-row flex-grow flex-center scroll-hidden relative bolt-link no-underline-link'; | |
| button.setAttribute('role', 'menuitem'); | |
| button.setAttribute('tabindex', '-1'); | |
| button.innerHTML = ` | |
| <span class="navigation-icon flex-row flex-center flex-noshrink justify-center"> | |
| <span class="fluent-icons-enabled" role="img" aria-hidden="true"> | |
| <span aria-hidden="true" class="navigation-icon flex-row flex-center justify-center flex-noshrink fabric-icon ms-Icon--FullScreen medium ado-header-toggle-icon"></span> | |
| </span> | |
| </span> | |
| <span class="navigation-text expanded-only text-ellipsis flex-grow">Headers</span> | |
| `; | |
| button.addEventListener('click', () => setHidden(!headersHidden)); | |
| container.append(button); | |
| return container; | |
| }; | |
| const inject = () => { | |
| if (document.getElementById(ROOT_ID)) { | |
| updateToggle(); | |
| return true; | |
| } | |
| const nav = document.querySelector('.navigation-container .project-navigation'); | |
| const section = nav?.querySelector('.navigation-section'); | |
| if (!section || !document.head) return false; | |
| ensureStyle(); | |
| section.append(buildToggle()); | |
| updateToggle(false); | |
| return true; | |
| }; | |
| document.getElementById(ROOT_ID)?.closest('.hub-group-container')?.remove(); | |
| document.getElementById(STYLE_ID)?.remove(); | |
| document.documentElement.classList.remove(HIDDEN_CLASS); | |
| const readyTimer = setInterval(() => { | |
| if (inject()) clearInterval(readyTimer); | |
| }, 250); | |
| new MutationObserver(inject).observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| })(); | |
| // assignee quick select buttons | |
| (() => { | |
| 'use strict'; | |
| const ROOT_ID = 'ado-assignee-quickpick-prototype'; | |
| const STYLE_ID = `${ROOT_ID}-style`; | |
| const TOGGLE_ID = `${ROOT_ID}-toggle`; | |
| const HIDDEN_CLASS = `${ROOT_ID}-hidden`; | |
| const EXCLUDED_ASSIGNEES = new Set(['@Me', 'Unassigned']); | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); | |
| const displayName = (name) => name.split(/\s+/)[0]; | |
| const isAssigneeName = (name) => Boolean(name) && !EXCLUDED_ASSIGNEES.has(name); | |
| let assignees = []; | |
| const visible = (el) => { | |
| if (!el) return false; | |
| const rect = el.getBoundingClientRect(); | |
| return rect.width > 0 && rect.height > 0; | |
| }; | |
| const assignedButton = () => document.querySelector('.vss-FilterBar button[aria-label^="Assigned to"]'); | |
| const filterButton = (ariaPrefix) => document.querySelector(`.vss-FilterBar button[aria-label^="${ariaPrefix}"]`); | |
| const statesButton = () => filterButton('States filter') || filterButton('States'); | |
| const filterBar = () => document.querySelector('.vss-FilterBar'); | |
| const dropdown = () => Array.from(document.querySelectorAll('.work-item-filter-dropdown')).find(visible); | |
| const listbox = () => { | |
| const openDropdown = dropdown(); | |
| if (!openDropdown) return null; | |
| return Array.from(openDropdown.querySelectorAll('ul[role="listbox"], [role="listbox"]')).find(visible) | |
| || (openDropdown.querySelector('li[role="option"]') ? openDropdown : null); | |
| }; | |
| const currentFromButton = () => { | |
| const button = assignedButton(); | |
| const aria = button?.getAttribute('aria-label') || ''; | |
| const match = aria.match(/^Assigned to filter:\s*(.+)$/); | |
| if (match) return match[1].trim(); | |
| const text = (button?.textContent || '').trim().replace(/\s+/g, ' '); | |
| return text && text !== 'Assigned to' ? text : null; | |
| }; | |
| const rows = () => Array.from(dropdown()?.querySelectorAll('li[role="option"]') || []); | |
| const rowName = (row) => (row.textContent || '').trim().replace(/\s+/g, ' '); | |
| const checkbox = (row) => row.querySelector('[role="checkbox"]'); | |
| const checkedRows = () => rows().filter((row) => checkbox(row)?.getAttribute('aria-checked') === 'true'); | |
| const currentSelection = () => checkedRows().map(rowName).filter(isAssigneeName); | |
| const listScroller = () => dropdown()?.querySelector('.bolt-dropdown-list-box-container'); | |
| const humanClick = (el) => { | |
| const rect = el.getBoundingClientRect(); | |
| const x = rect.left + Math.min(24, Math.max(1, rect.width / 2)); | |
| const y = rect.top + rect.height / 2; | |
| const options = { | |
| bubbles: true, | |
| cancelable: true, | |
| composed: true, | |
| view: window, | |
| clientX: x, | |
| clientY: y, | |
| button: 0, | |
| buttons: 1, | |
| }; | |
| el.dispatchEvent(new PointerEvent('pointerdown', { ...options, pointerId: 1, pointerType: 'mouse', isPrimary: true })); | |
| el.dispatchEvent(new MouseEvent('mousedown', options)); | |
| el.dispatchEvent(new PointerEvent('pointerup', { ...options, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 })); | |
| el.dispatchEvent(new MouseEvent('mouseup', { ...options, buttons: 0 })); | |
| el.dispatchEvent(new MouseEvent('click', { ...options, buttons: 0 })); | |
| }; | |
| const clickRow = async (row) => { | |
| humanClick(row); | |
| await nextFrame(); | |
| }; | |
| const waitFor = async (fn, timeout = 4000) => { | |
| const start = Date.now(); | |
| while (Date.now() - start < timeout) { | |
| const value = fn(); | |
| if (value) return value; | |
| await sleep(20); | |
| } | |
| return null; | |
| }; | |
| const openAssigned = async () => { | |
| const opened = await openFilterDropdown(assignedButton(), 'Assigned to filter button not found.'); | |
| return opened; | |
| }; | |
| const openFilterDropdown = async (button, missingMessage = 'Filter button not found.') => { | |
| if (listbox()) return true; | |
| if (!button) throw new Error(missingMessage); | |
| humanClick(button); | |
| if (await waitFor(listbox, 1000)) return true; | |
| button.focus(); | |
| button.dispatchEvent(new KeyboardEvent('keydown', { | |
| key: 'Enter', | |
| code: 'Enter', | |
| keyCode: 13, | |
| which: 13, | |
| bubbles: true, | |
| cancelable: true, | |
| })); | |
| button.dispatchEvent(new KeyboardEvent('keyup', { | |
| key: 'Enter', | |
| code: 'Enter', | |
| keyCode: 13, | |
| which: 13, | |
| bubbles: true, | |
| cancelable: true, | |
| })); | |
| return Boolean(await waitFor(() => dropdown() && rows().length > 0)); | |
| }; | |
| const closeDropdown = async (button = assignedButton()) => { | |
| if (button?.getAttribute('aria-expanded') === 'true') { | |
| humanClick(button); | |
| await nextFrame(); | |
| if (!listbox()) return; | |
| } | |
| window.dispatchEvent(new KeyboardEvent('keydown', { | |
| key: 'Escape', | |
| code: 'Escape', | |
| keyCode: 27, | |
| which: 27, | |
| bubbles: true, | |
| })); | |
| document.dispatchEvent(new KeyboardEvent('keydown', { | |
| key: 'Escape', | |
| code: 'Escape', | |
| keyCode: 27, | |
| which: 27, | |
| bubbles: true, | |
| })); | |
| await nextFrame(); | |
| if (listbox()) { | |
| document.body.click(); | |
| await nextFrame(); | |
| } | |
| }; | |
| const closeAssigned = async () => closeDropdown(assignedButton()); | |
| const collectAssigneeNames = async () => { | |
| const seen = new Set(); | |
| const names = []; | |
| const captureVisibleNames = () => { | |
| for (const name of rows().map(rowName)) { | |
| if (!isAssigneeName(name) || seen.has(name)) continue; | |
| seen.add(name); | |
| names.push(name); | |
| } | |
| }; | |
| captureVisibleNames(); | |
| const scroller = listScroller(); | |
| if (!scroller) return names; | |
| scroller.scrollTop = 0; | |
| scroller.dispatchEvent(new Event('scroll', { bubbles: true })); | |
| await nextFrame(); | |
| captureVisibleNames(); | |
| let previousTop = -1; | |
| while (scroller.scrollTop !== previousTop) { | |
| previousTop = scroller.scrollTop; | |
| scroller.scrollTop += Math.max(31, scroller.clientHeight - 31); | |
| scroller.dispatchEvent(new Event('scroll', { bubbles: true })); | |
| await nextFrame(); | |
| captureVisibleNames(); | |
| } | |
| return names; | |
| }; | |
| const clearSelection = async (knownName = null) => { | |
| if (knownName) { | |
| const knownRow = await findRowByScrolling(knownName); | |
| if (knownRow && checkbox(knownRow)?.getAttribute('aria-checked') === 'true') { | |
| await clickRow(knownRow); | |
| await waitFor(() => { | |
| const row = findVisibleRow(knownName); | |
| return !row || checkbox(row)?.getAttribute('aria-checked') === 'false'; | |
| }, 2000); | |
| return; | |
| } | |
| } | |
| const clearButton = Array.from(dropdown()?.querySelectorAll('button') || []) | |
| .find((button) => (button.textContent || '').trim() === 'Clear' && !button.disabled && !button.classList.contains('disabled')); | |
| if (clearButton) { | |
| humanClick(clearButton); | |
| await waitFor(() => checkedRows().length === 0, 1000); | |
| return; | |
| } | |
| for (const row of checkedRows()) { | |
| await clickRow(row); | |
| } | |
| }; | |
| const findVisibleRow = (name) => rows().find((row) => rowName(row) === name); | |
| const findRowByScrolling = async (name) => { | |
| const scroller = listScroller(); | |
| if (!scroller) return null; | |
| scroller.scrollTop = 0; | |
| await nextFrame(); | |
| let previousTop = -1; | |
| while (scroller.scrollTop !== previousTop) { | |
| const row = findVisibleRow(name); | |
| if (row) return row; | |
| previousTop = scroller.scrollTop; | |
| scroller.scrollTop += Math.max(31, scroller.clientHeight - 31); | |
| scroller.dispatchEvent(new Event('scroll', { bubbles: true })); | |
| await nextFrame(); | |
| } | |
| return findVisibleRow(name); | |
| }; | |
| const selectSingle = async (name) => { | |
| const currentBeforeOpen = currentFromButton(); | |
| setStatus(`Opening Assigned to...`); | |
| if (!(await openAssigned())) throw new Error('Assigned to dropdown did not open.'); | |
| const before = currentSelection(); | |
| const currentName = before.length === 1 ? before[0] : currentBeforeOpen; | |
| const sameSelection = currentName === name; | |
| setStatus(sameSelection ? `Clearing ${name}...` : `Clearing existing selection...`); | |
| await clearSelection(currentName); | |
| if (!sameSelection) { | |
| setStatus(`Selecting ${name}...`); | |
| const target = await findRowByScrolling(name); | |
| if (!target) throw new Error(`Could not find assignee row: ${name}`); | |
| if (checkbox(target)?.getAttribute('aria-checked') !== 'true') { | |
| await clickRow(target); | |
| await waitFor(() => { | |
| const row = findVisibleRow(name); | |
| return row && checkbox(row)?.getAttribute('aria-checked') === 'true'; | |
| }, 2000); | |
| } | |
| } | |
| const after = sameSelection ? [] : [name]; | |
| await closeAssigned(); | |
| setActive(after.length === 1 ? after[0] : null); | |
| setStatus(after.length ? `Selected ${after[0]}` : 'No assignee filter'); | |
| }; | |
| const clearFilterBar = async () => { | |
| const clearButton = document.querySelector('.vss-FilterBar button[aria-label="Clear filters"]'); | |
| if (!clearButton || clearButton.disabled || clearButton.classList.contains('disabled')) return; | |
| humanClick(clearButton); | |
| await waitFor(() => !currentFromButton(), 1000); | |
| }; | |
| const placeQuickPicks = async () => { | |
| const bar = await ensureFilterBar(); | |
| if (!bar) throw new Error('Filter bar not found.'); | |
| if (root.nextElementSibling !== bar) { | |
| bar.before(root); | |
| } | |
| return bar; | |
| }; | |
| const ensureFilterBar = async () => { | |
| if (filterBar()) return filterBar(); | |
| const toggle = document.querySelector('#__bolt-filter') | |
| || document.querySelector('button[aria-label="Filter"]'); | |
| if (!toggle) return null; | |
| humanClick(toggle); | |
| const bar = await waitFor(filterBar, 2000); | |
| if (!bar) return null; | |
| await waitFor(() => assignedButton() && statesButton(), 2000); | |
| return bar; | |
| }; | |
| const setStatesExceptDone = async () => { | |
| const button = await waitFor(statesButton, 2000); | |
| setStatus('Opening States...'); | |
| if (!(await openFilterDropdown(button, 'States filter button not found.'))) { | |
| throw new Error('States dropdown did not open.'); | |
| } | |
| const stateRows = rows().filter((row) => rowName(row)); | |
| for (const row of stateRows) { | |
| const name = rowName(row); | |
| const isDone = name === 'Done'; | |
| const isChecked = checkbox(row)?.getAttribute('aria-checked') === 'true'; | |
| if ((isDone && isChecked) || (!isDone && !isChecked)) { | |
| await clickRow(row); | |
| } | |
| } | |
| await closeDropdown(button); | |
| }; | |
| const prepareVisibleFilters = async () => { | |
| setStatus('Resetting filters...'); | |
| await placeQuickPicks(); | |
| await closeDropdown(); | |
| await clearFilterBar(); | |
| await nextFrame(); | |
| await waitFor(statesButton, 2000); | |
| setActive(null); | |
| await syncAssignees(); | |
| await setStatesExceptDone(); | |
| await placeQuickPicks(); | |
| setStatus('Ready'); | |
| }; | |
| const setStatus = (text) => { | |
| const status = document.querySelector(`#${ROOT_ID} .ado-assignee-status`); | |
| if (status) status.textContent = text; | |
| }; | |
| const setActive = (name) => { | |
| document.querySelectorAll(`#${ROOT_ID} [data-assignee]`).forEach((button) => { | |
| button.classList.toggle('active', button.dataset.assignee === name); | |
| }); | |
| }; | |
| document.getElementById(ROOT_ID)?.remove(); | |
| document.getElementById(TOGGLE_ID)?.closest('.hub-group-container')?.remove(); | |
| document.getElementById(STYLE_ID)?.remove(); | |
| document.documentElement.classList.add(HIDDEN_CLASS); | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| html.${HIDDEN_CLASS} #${ROOT_ID} { | |
| display: none; | |
| } | |
| #${ROOT_ID} { | |
| align-items: center; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| margin: 6px 80px; | |
| position: relative; | |
| z-index: 20; | |
| } | |
| #${ROOT_ID} button { | |
| background: var(--palette-neutral-4, #f3f2f1); | |
| border: 1px solid var(--palette-neutral-20, #c8c6c4); | |
| border-radius: 3px; | |
| color: inherit; | |
| cursor: pointer; | |
| font: 12px var(--fontFamily, inherit); | |
| line-height: 18px; | |
| min-height: 24px; | |
| padding: 2px 6px; | |
| } | |
| #${ROOT_ID} button:hover, | |
| #${ROOT_ID} button.active { | |
| background: rgba(0, 120, 212, 0.14); | |
| border-color: rgb(0, 120, 212); | |
| } | |
| #${ROOT_ID} .ado-assignee-status { | |
| color: var(--text-secondary-color, #605e5c); | |
| display: none; | |
| font-size: 12px; | |
| margin-left: 6px; | |
| } | |
| #${TOGGLE_ID} { | |
| align-items: center; | |
| background: transparent; | |
| border: 0; | |
| color: inherit; | |
| cursor: pointer; | |
| display: flex; | |
| flex-shrink: 0; | |
| font: inherit; | |
| height: 40px; | |
| justify-content: center; | |
| margin: 0; | |
| padding: 0; | |
| text-align: left; | |
| width: 100%; | |
| } | |
| #${TOGGLE_ID}:hover, | |
| #${TOGGLE_ID}:focus-visible, | |
| #${TOGGLE_ID}.active { | |
| background: rgba(0, 120, 212, 0.12); | |
| } | |
| #${TOGGLE_ID}.active .navigation-icon { | |
| color: rgb(0, 120, 212); | |
| } | |
| `; | |
| document.head.append(style); | |
| const root = document.createElement('div'); | |
| root.id = ROOT_ID; | |
| const status = document.createElement('span'); | |
| status.className = 'ado-assignee-status'; | |
| status.textContent = 'Prototype ready'; | |
| root.append(status); | |
| const renderAssigneeButtons = () => { | |
| root.querySelectorAll('[data-assignee]').forEach((button) => button.remove()); | |
| const fragment = document.createDocumentFragment(); | |
| for (const name of assignees) { | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.dataset.assignee = name; | |
| button.title = name; | |
| button.textContent = displayName(name); | |
| fragment.append(button); | |
| } | |
| root.insertBefore(fragment, status); | |
| }; | |
| const syncAssignees = async () => { | |
| setStatus('Loading assignees...'); | |
| if (!(await openAssigned())) throw new Error('Assigned to dropdown did not open.'); | |
| const names = await collectAssigneeNames(); | |
| await closeAssigned(); | |
| if (!names.length) throw new Error('No assignees found in Assigned to dropdown.'); | |
| assignees = names; | |
| renderAssigneeButtons(); | |
| }; | |
| const buildToggle = () => { | |
| const container = document.createElement('div'); | |
| container.className = 'hub-group-container flex-column flex-noshrink relative hub-group-only'; | |
| const button = document.createElement('button'); | |
| button.id = TOGGLE_ID; | |
| button.type = 'button'; | |
| button.className = 'hub-group navigation-element navigation-link focus-treatment flex-row flex-grow flex-center scroll-hidden relative bolt-link no-underline-link'; | |
| button.setAttribute('role', 'menuitem'); | |
| button.setAttribute('tabindex', '-1'); | |
| button.setAttribute('aria-pressed', 'false'); | |
| button.setAttribute('aria-label', 'Show assignee quick picks'); | |
| button.title = 'Show assignee quick picks'; | |
| button.innerHTML = ` | |
| <span class="navigation-icon flex-row flex-center flex-noshrink justify-center"> | |
| <span class="fluent-icons-enabled" role="img" aria-hidden="true"> | |
| <span aria-hidden="true" class="navigation-icon flex-row flex-center justify-center flex-noshrink fabric-icon ms-Icon--People medium"></span> | |
| </span> | |
| </span> | |
| <span class="navigation-text expanded-only text-ellipsis flex-grow">Assignees</span> | |
| `; | |
| button.addEventListener('click', async () => { | |
| const hidden = document.documentElement.classList.toggle(HIDDEN_CLASS); | |
| button.classList.toggle('active', !hidden); | |
| button.setAttribute('aria-pressed', String(!hidden)); | |
| button.setAttribute('aria-label', hidden ? 'Show assignee quick picks' : 'Hide assignee quick picks'); | |
| button.title = hidden ? 'Show assignee quick picks' : 'Hide assignee quick picks'; | |
| if (!hidden) { | |
| button.disabled = true; | |
| try { | |
| await prepareVisibleFilters(); | |
| } catch (error) { | |
| console.error(error); | |
| setStatus(error.message); | |
| } finally { | |
| button.disabled = false; | |
| } | |
| } | |
| }); | |
| container.append(button); | |
| return container; | |
| }; | |
| const navSection = document.querySelector('.navigation-container .project-navigation .navigation-section'); | |
| navSection?.append(buildToggle()); | |
| root.addEventListener('click', async (event) => { | |
| const button = event.target.closest('button'); | |
| if (!button) return; | |
| button.disabled = true; | |
| try { | |
| if (button.dataset.assignee) { | |
| await selectSingle(button.dataset.assignee); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| setStatus(error.message); | |
| } finally { | |
| button.disabled = false; | |
| } | |
| }); | |
| console.log('ADO assignee quick-pick prototype injected.'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment