Todoist Show Navigation Shortcuts latest version (currently v1.0.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Todoist: Show Navigation Shortcuts
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Permanently displays keyboard shortcuts next to navigation items in Todoist's left sidebar
// @author       Tim Hilton using Copilot
// @match        https://app.todoist.com/app/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Todoist: Show Navigation Shortcuts]';
    const BADGE_CLASS = 'tjhleeds-nav-shortcut';
    let observerCallCount = 0;

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

    // Keyboard shortcut definitions for Todoist navigation items (Windows / web).
    // keys: array of key groups.
    //   - One group = keys pressed simultaneously, e.g. [['Ctrl', 'K']] renders [Ctrl][K].
    //   - Multiple groups = sequential presses, e.g. [['G'], ['I']] renders [G] then [I].
    //
    // Matchers use terminal-anchor regexes (ending with $) so that, for example,
    // /app/filters/12345 (an individual filter page) does NOT match the
    // "Filters & Labels" nav item whose href is /app/filters or /app/filters/.
    // Text-content matching is intentionally avoided to prevent false positives on
    // custom filters with names like "Today non work" or "Upcoming weekend".
    const NAV_SHORTCUTS = [
        {
            name: 'Search',
            keys: [['Ctrl', 'K']],
            // Exact aria-label match only — avoids picking up unrelated buttons that
            // contain the word "search" somewhere in a longer label.
            matchers: [
                (el) => /^(open\s+)?quick\s*find$/i.test((el.getAttribute('aria-label') ?? '').trim()),
                (el) => /^search$/i.test((el.getAttribute('aria-label') ?? '').trim()),
            ],
        },
        {
            name: 'Inbox',
            keys: [['G'], ['I']],
            matchers: [(el) => /\/app\/inbox\/?$/.test(el.getAttribute('href') ?? '')],
        },
        {
            name: 'Today',
            keys: [['G'], ['T']],
            matchers: [(el) => /\/app\/today\/?$/.test(el.getAttribute('href') ?? '')],
        },
        {
            name: 'Upcoming',
            keys: [['G'], ['U']],
            matchers: [(el) => /\/app\/upcoming\/?$/.test(el.getAttribute('href') ?? '')],
        },
        {
            name: 'Filters & Labels',
            keys: [['G'], ['V']],
            // /app/filters or /app/filters/ but NOT /app/filters/12345.
            matchers: [(el) => /\/app\/filters\/?$/.test(el.getAttribute('href') ?? '')],
        },
    ];

    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------

    /** Return true if the element matches any of the shortcut's matchers. */
    function matchesShortcut(el, shortcutDef) {
        return shortcutDef.matchers.some((matcher) => {
            try {
                return matcher(el);
            } catch {
                return false;
            }
        });
    }

    /**
     * Return true if the element lives inside the main page content area or a
     * breadcrumb, rather than in the left sidebar.  Used to exclude breadcrumb
     * links that share the same href as a main nav item (e.g. "Filters & Labels"
     * in the page header) and controls embedded in task-list section headers.
     */
    function isInMainContent(el) {
        return !!(
            el.closest('[role="main"]') ||
            el.closest('main') ||
            el.closest('header') ||
            el.closest('[class*="view_header"]') ||
            el.closest('[class*="top_bar"]') ||
            el.closest('[class*="topbar"]') ||
            el.closest('[class*="breadcrumb"]') ||
            el.closest('[aria-label*="breadcrumb" i]')
        );
    }

    /**
     * Build a styled shortcut badge element.
     * Each key group is separated by "then"; keys within a group are adjacent
     * (like Todoist's own tooltip styling).
     */
    function createShortcutBadge(keys) {
        const container = document.createElement('span');
        container.className = BADGE_CLASS;
        container.setAttribute('aria-hidden', 'true');
        Object.assign(container.style, {
            display: 'inline-flex',
            alignItems: 'center',
            gap: '2px',
            marginLeft: 'auto',
            flexShrink: '0',
            paddingLeft: '8px',
            pointerEvents: 'none',
        });

        keys.forEach((keyGroup, groupIndex) => {
            if (groupIndex > 0) {
                const sep = document.createElement('span');
                sep.textContent = 'then';
                Object.assign(sep.style, {
                    fontSize: '10px',
                    color: 'rgba(255,255,255,0.45)',
                    margin: '0 2px',
                    fontFamily: 'inherit',
                });
                container.appendChild(sep);
            }

            keyGroup.forEach((key) => {
                const kbd = document.createElement('kbd');
                kbd.textContent = key;
                Object.assign(kbd.style, {
                    display: 'inline-block',
                    padding: '1px 4px',
                    border: '1px solid rgba(255,255,255,0.25)',
                    borderRadius: '3px',
                    fontSize: '11px',
                    fontFamily: 'inherit',
                    lineHeight: '1.4',
                    color: 'rgba(255,255,255,0.65)',
                    background: 'rgba(255,255,255,0.07)',
                    minWidth: '16px',
                    textAlign: 'center',
                    boxShadow: '0 1px 0 rgba(255,255,255,0.15)',
                    fontWeight: 'normal',
                });
                container.appendChild(kbd);
            });
        });

        return container;
    }

    /** Inject a shortcut badge into a nav element (idempotent). */
    function injectShortcutBadge(navEl, shortcutDef) {
        if (navEl.querySelector(`.${BADGE_CLASS}`)) {
            console.debug(`${LOG_PREFIX} Badge already present for: ${shortcutDef.name}`);
            return;
        }

        console.debug(`${LOG_PREFIX} Injecting shortcut badge for: ${shortcutDef.name}`);
        navEl.appendChild(createShortcutBadge(shortcutDef.keys));
        console.log(`${LOG_PREFIX} ✅ Shortcut badge added for: ${shortcutDef.name}`);
    }

    // ---------------------------------------------------------------------------
    // Main processing
    // ---------------------------------------------------------------------------

    function processNavItems() {
        console.debug(`${LOG_PREFIX} Processing navigation items`);

        // Build a deduplicated list of candidate elements using targeted selectors.
        // The generic "nav a / nav button" fallback has been intentionally removed
        // because it also selects custom filter items in the sidebar and controls
        // embedded in task-list section headers.
        const seen = new Set();
        const candidates = [
            // Main navigation links (exact path only — terminal $ in matchers prevents
            // /app/today-something or /app/filters/12345 from matching).
            ...document.querySelectorAll(
                'a[href*="/app/inbox"], a[href*="/app/today"], a[href*="/app/upcoming"], a[href*="/app/filters"]'
            ),
            // Search is a button in the sidebar — select only buttons with an aria-label.
            ...document.querySelectorAll('button[aria-label], [role="button"][aria-label]'),
        ].filter((el) => {
            // Exclude anything inside the main content area or breadcrumb so that
            // breadcrumb links sharing the same href as a nav item are not matched,
            // and so that buttons embedded in task-list headers are not matched.
            if (isInMainContent(el)) {
                console.debug(`${LOG_PREFIX} Excluding element in main content: ${el.tagName} href=${el.getAttribute('href') ?? ''} label=${el.getAttribute('aria-label') ?? ''}`);
                return false;
            }
            if (seen.has(el)) return false;
            seen.add(el);
            return true;
        });

        console.debug(`${LOG_PREFIX} ${candidates.length} candidate element(s) found`);

        let matched = 0;
        for (const el of candidates) {
            for (const shortcutDef of NAV_SHORTCUTS) {
                if (matchesShortcut(el, shortcutDef)) {
                    injectShortcutBadge(el, shortcutDef);
                    matched++;
                    break;
                }
            }
        }

        console.debug(`${LOG_PREFIX} Matched ${matched} navigation item(s)`);
    }

    // ---------------------------------------------------------------------------
    // Observers
    // ---------------------------------------------------------------------------

    // Run immediately in case the sidebar is already in the DOM.
    processNavItems();

    // Watch for DOM changes (Todoist is a React SPA and renders asynchronously).
    const domObserver = new MutationObserver((mutations) => {
        observerCallCount++;
        console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${observerCallCount})`);

        const hasAdditions = mutations.some(
            (m) => m.type === 'childList' && m.addedNodes.length > 0
        );
        if (hasAdditions) {
            processNavItems();
        }
    });

    // Prefer observing the sidebar element for better performance; fall back to body.
    const sidebarSelectors = ['nav', '[role="navigation"]', 'aside', '#navigation', '.navigation'];
    let observationRoot = null;
    for (const sel of sidebarSelectors) {
        observationRoot = document.querySelector(sel);
        if (observationRoot) {
            console.debug(`${LOG_PREFIX} Observing sidebar element: ${sel}`);
            break;
        }
    }
    if (!observationRoot) {
        console.debug(`${LOG_PREFIX} No sidebar element found yet — observing document.body`);
        observationRoot = document.body;
    }

    domObserver.observe(observationRoot, { childList: true, subtree: true });

    // Re-process after client-side navigation (URL changes).
    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            console.debug(`${LOG_PREFIX} URL changed to ${lastUrl} — re-processing navigation`);
            setTimeout(processNavItems, 500);
        }
    }).observe(document, { subtree: true, childList: true });

})();