Jira - Custom script specific for work usecase

Changes jira display for custom label styling. Specific for my usecase. Don't bother downloading if you are not a coworker.

// ==UserScript==
// @name         Jira - Custom script specific for work usecase
// @namespace    http://tampermonkey.net/
// @version      6.8
// @description  Changes jira display for custom label styling. Specific for my usecase. Don't bother downloading if you are not a coworker.
// @author       Roy
// @match        https://jira.onderwijstransparant.nl/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    /**
     * Injecteert een <style> tag in de <head> van de pagina met alle custom CSS.
     * Dit wordt één keer uitgevoerd en voorkomt dat stijlen inline of herhaaldelijk worden toegevoegd.
     */
    function injectCustomStyles() {
        const styleId = 'jira-custom-card-styles';
        // Voorkom het dubbel injecteren van de stijlen.
        if (document.getElementById(styleId)) return;

        const style = document.createElement('style');
        style.id = styleId;
        style.innerHTML = `
            /* Geeft tickets van het type 'Bug' een lichtrode achtergrond. */
            .ghx-issue-compact.ghx-bug-card,
            .ghx-issue-compact.ghx-bug-card .ghx-end {
                background-color: #ffe7e7 !important;
            }

            /* Stijl voor het categorie-label dat uit de titel wordt gehaald. */
            .aui-label.ghx-title-label {
                background-color: #ffffff;
                color: #333;
                border: 1px solid #ccc;
            }

            /* --- DIVIDER STIJLEN --- */
            /* Stijlen voor een ticket met het label 'divider', om het als scheidingslijn te tonen. */
            .ghx-issue-compact.ghx-divider {
                height: auto !important;
                margin: 5px 0 !important;
                background: transparent !important;
                border: none !important;
                box-shadow: none !important;
                display: flex !important;
                align-items: center;
                justify-content: center;
                padding: 10px 0;
                position: relative;
                overflow: visible;
                cursor: move !important;
            }
            /* De horizontale lijn van de divider. */
            .ghx-issue-compact.ghx-divider::before {
                content: '';
                position: absolute;
                left: 0;
                right: 0;
                top: 50%;
                height: 1px;
                background-color: #ccc;
                z-index: 0;
            }
            /* Verberg alle standaard ticket-elementen behalve de titel. */
            .ghx-issue-compact.ghx-divider .ghx-row > *:not(.ghx-summary) { display: none !important; }
            /* Stijl de titel van de divider als een gecentreerd label op de lijn. */
            .ghx-issue-compact.ghx-divider .ghx-summary {
                display: block !important; text-align: center; font-size: 14px;
                font-weight: bold; color: #707070; background-color: #f5f5f5;
                padding: 2px 15px; border-radius: 10px; position: relative;
                z-index: 1; flex-grow: 0 !important; opacity: 1 !important;
            }
            /* Verberg overige elementen van de divider. */
            .ghx-issue-compact.ghx-divider .ghx-grabber,
            .ghx-issue-compact.ghx-divider .ghx-end,
            .ghx-issue-compact.ghx-divider .ghx-plan-extra-fields { display: none !important; }
            .ghx-issue-compact.ghx-divider .ghx-row { display: block !important; text-align: center; }

            /* --- LABEL STIJLEN --- */
            /* Stijl voor 'tijdskritisch' label, met een icoon en opvallende kleur. */
            .aui-label.ghx-time-critical-label {
                background-color: #ff5722; /* Levendig oranje-rood */
                color: white;
                border-color: #e64a19;
                display: flex;
                align-items: center;
                gap: 5px; /* Ruimte tussen icoon en tekst */
                font-weight: bold;
                border-radius: 3px;
            }


            /* --- STATUS PROGRESSIE ICOON STIJLEN --- */
            /* De container voor het status-icoon (de 3 balkjes). */
            .ghx-status-indicator {
                display: flex;
                flex-direction: column-reverse; /* Zorgt ervoor dat de balkjes van onder naar boven gevuld worden. */
                gap: 1px;
                width: 8px;
                height: 11px; /* 3 balkjes * 3px hoogte + 2 * 1px tussenruimte */
                justify-content: center;
                flex-shrink: 0;
                margin: 0 2px;
                vertical-align: middle;
            }
            /* Stijl voor een enkel balkje binnen het status-icoon. */
            .ghx-status-indicator-bar {
                height: 3px; /* Vaste hoogte voor consistentie. */
                width: 100%;
                background-color: #fff; /* Wit voor 'lege' balkjes. */
                border: 1px solid #ccc; /* Grijze rand voor 'lege' balkjes. */
                box-sizing: border-box; /* Zorgt dat de rand binnen de hoogte valt. */
            }

            /* Specifieke kleuren voor de balkjes op basis van de status van het ticket. */
            .ghx-issue-compact.status-inprogress .ghx-status-indicator-bar:nth-child(-n+1) {
                background-color: #59afe1; border-color: #59afe1;
            }
            .ghx-issue-compact.status-test .ghx-status-indicator-bar:nth-child(-n+2) {
                background-color: #f6c342; border-color: #f6c342;
            }
            .ghx-issue-compact.status-done .ghx-status-indicator-bar,
            .ghx-issue-compact.status-closed .ghx-status-indicator-bar {
                background-color: #8eb021; border-color: #8eb021;
            }
        `;
        document.head.appendChild(style);
    }

    /**
     * Maakt een gestyled icoon voor 'small', 'medium', 'large' labels.
     * @param {string} sizeText - De tekst voor het icoon (S, M, of L).
     * @returns {HTMLElement} Het HTML-element voor het icoon.
     */
    function createSizeIcon(sizeText) {
        const icon = document.createElement('div');
        icon.textContent = sizeText;
        Object.assign(icon.style, {
            width: '24px', height: '24px', borderRadius: '50%', backgroundColor: '#f5f5f5',
            border: '1px solid #ccc', color: '#333', display: 'flex', alignItems: 'center',
            justifyContent: 'center', fontWeight: 'bold', fontSize: '12px',
            flexShrink: '0', fontFamily: 'Arial, sans-serif'
        });
        return icon;
    }

    /**
     * Configuratie-object voor custom acties op basis van labelnamen.
     * Keys moeten in lowercase zijn voor case-insensitive matching.
     */
    const labelActions = {
        'divider': {
            type: 'custom',
            handler: function(card) {
                if (card.classList.contains('ghx-divider')) return false;
                card.classList.add('ghx-divider');

                const summary = card.querySelector('.ghx-summary');
                if (summary) {
                    summary.style.opacity = '1';
                    const inner = summary.querySelector('.ghx-inner');
                    if (inner && inner.dataset.originalTitle) {
                        inner.innerHTML = inner.dataset.originalTitle;
                    }
                }
                card.querySelectorAll('.ghx-plan-extra-fields, .ghx-end.ghx-row').forEach(el => el.remove());
                return false;
            }
        },
        'p048': { type: 'style', styles: { backgroundColor: '#f79232', color: '#fff', borderColor: '#f79232' } },
        'urgent': { type: 'style', styles: { backgroundColor: '#d04437', color: 'white', borderColor: '#d04437' } },
        'on-hold': {
            type: 'custom',
            handler: function(card, labelText, rightContainer) {
                if (card.querySelector('.on-hold-label')) return false;
                const onHoldLabel = document.createElement('span');
                onHoldLabel.textContent = `🛑 On Hold`;
                onHoldLabel.className = 'aui-label on-hold-label';
                Object.assign(onHoldLabel.style, { marginLeft: '5px', flexShrink: '0' });
                const keyElement = card.querySelector('.ghx-key');
                const summaryElement = card.querySelector('.ghx-summary');
                if (keyElement) keyElement.insertAdjacentElement('afterend', onHoldLabel);
                if (summaryElement) summaryElement.style.opacity = '0.6';
                return false;
            }
        },
        'small': { type: 'custom', handler: function(card, labelText, rightContainer) { if (!card.querySelector('.size-icon-s')) { const icon = createSizeIcon('S'); icon.classList.add('size-icon-s'); rightContainer.appendChild(icon); } return false; } },
        'medium': { type: 'custom', handler: function(card, labelText, rightContainer) { if (!card.querySelector('.size-icon-m')) { const icon = createSizeIcon('M'); icon.classList.add('size-icon-m'); rightContainer.appendChild(icon); } return false; } },
        'large': { type: 'custom', handler: function(card, labelText, rightContainer) { if (!card.querySelector('.size-icon-l')) { const icon = createSizeIcon('L'); icon.classList.add('size-icon-l'); rightContainer.appendChild(icon); } return false; } },
        'onderzoek': {
            type: 'custom',
            handler: function(card, labelText, rightContainer) {
                if (card.querySelector('.research-label')) return false;
                const researchLabel = document.createElement('span');
                researchLabel.className = 'aui-label research-label';
                researchLabel.innerHTML = '🔍 Onderzoek';
                Object.assign(researchLabel.style, { borderColor: '#3572b0', display: 'flex', alignItems: 'center', gap: '4px' });
                rightContainer.appendChild(researchLabel);
                return false;
            }
        },
        'tijdskritisch': {
            type: 'custom',
            handler: function(card, labelText, rightContainer) {
                if (card.querySelector('.ghx-time-critical-label')) return false; // Voorkom duplicaten
                const timeCriticalLabel = document.createElement('span');
                timeCriticalLabel.className = 'aui-label ghx-time-critical-label';
                timeCriticalLabel.innerHTML = '⏰ Tijdskritisch'; // Icoon + tekst
                rightContainer.appendChild(timeCriticalLabel);
                return false; // Voorkom dat de standaard label-logica wordt uitgevoerd
            }
        },
        'release': {
            type: 'custom',
            handler: function(card, labelText, rightContainer) {
                if (card.querySelector('.release-label')) return false;
                const releaseLabel = document.createElement('span');
                releaseLabel.className = 'aui-label release-label';
                releaseLabel.innerHTML = '🔨 Uitvoeren release';
                Object.assign(releaseLabel.style, { display: 'flex', alignItems: 'center', gap: '4px' });
                rightContainer.appendChild(releaseLabel);
                return false;
            }
        }
    };

    /**
     * De hoofdfunctie die een enkele issue-kaart verwerkt en de DOM aanpast.
     * @param {HTMLElement} card - Het DOM-element van de issue-kaart.
     */
    function processSingleCard(card) {
        // --- Stap 1: Check voor speciale kaart-types (zoals 'divider') ---
        const labelTooltip = card.querySelector('span[data-tooltip^="Labels:"]');
        if (labelTooltip && labelTooltip.dataset.tooltip.toLowerCase().includes('divider')) {
            const action = labelActions['divider'];
            if (action && action.handler) {
                action.handler(card);
            }
            card.classList.add('ghx-layout-processed');
            return;
        }

        // --- Stap 2: Standaard kaart verwerking. ---
        card.querySelector('.ghx-flags')?.remove();

        const typeSpan = card.querySelector('.ghx-type');
        if (typeSpan && typeSpan.title.toLowerCase() === 'bug') {
            card.classList.add('ghx-bug-card');
        } else {
            card.classList.remove('ghx-bug-card');
        }

        // Reset eventuele eerdere aanpassingen om duplicaten te voorkomen.
        const summaryElementReset = card.querySelector('.ghx-summary');
        if (summaryElementReset) {
            summaryElementReset.style.opacity = '1';
            const innerSpan = summaryElementReset.querySelector('.ghx-inner');
            if (innerSpan && innerSpan.dataset.originalTitle) {
                innerSpan.innerHTML = innerSpan.dataset.originalTitle;
            }
        }

        const issueContent = card.querySelector('.ghx-issue-content');
        if (!issueContent) return;

        const mainRow = issueContent.querySelector('.ghx-row');
        if (!mainRow) return;

        mainRow.querySelectorAll('.on-hold-label, .ghx-status-indicator').forEach(el => el.remove());
        Object.assign(mainRow.style, { display: 'flex', alignItems: 'center', gap: '5px' });

        // Creëer containers om de rechter-elementen in de juiste volgorde te groeperen.
        const masterRightContainer = document.createElement('span');
        masterRightContainer.className = 'ghx-end';
        Object.assign(masterRightContainer.style, { display: 'flex', alignItems: 'center', gap: '8px', marginLeft: 'auto', flexShrink: '0' });

        const labelsSubContainer = document.createElement('span');
        Object.assign(labelsSubContainer.style, { display: 'flex', alignItems: 'center', gap: '5px' });

        const sizeIconsSubContainer = document.createElement('span');
        Object.assign(sizeIconsSubContainer.style, { display: 'flex', alignItems: 'center', gap: '5px' });

        const titleCategorySubContainer = document.createElement('span');
        Object.assign(titleCategorySubContainer.style, { display: 'flex', alignItems: 'center', gap: '5px' });

        const otherFieldsSubContainer = document.createElement('span');
        Object.assign(otherFieldsSubContainer.style, { display: 'flex', alignItems: 'center', gap: '5px' });




        // Verzamel alle elementen uit de extra-velden rijen.
        const elementsToProcess = [];
        const containersToRemove = [];
        card.querySelectorAll('.ghx-plan-extra-fields, .ghx-issue-content > .ghx-end.ghx-row').forEach(container => {
            elementsToProcess.push(...container.children);
            containersToRemove.push(container);
        });

        // Verwerk de status van het ticket en voeg het status-icoon toe.
        const statusField = elementsToProcess.find(field => field.matches('span[data-tooltip^="Status:"]'));
        if (statusField) {
            const statusText = (statusField.dataset.tooltip || '').replace('Status:', '').trim().toLowerCase();
            const statusClasses = ['status-open', 'status-reopened', 'status-inprogress', 'status-test', 'status-closed', 'status-done'];
            card.classList.remove(...statusClasses);

            const statusMap = {
                'open': { class: 'status-open', name: 'Open' },
                'reopened': { class: 'status-reopened', name: 'Reopened' },
                'in progress': { class: 'status-inprogress', name: 'In Progress' },
                'test': { class: 'status-test', name: 'Test' },
                'closed': { class: 'status-closed', name: 'Closed' },
                'done': { class: 'status-done', name: 'Done' }
            };

            if (statusMap[statusText]) {
                card.classList.add(statusMap[statusText].class);
                const progressIndicator = document.createElement('div');
                progressIndicator.className = 'ghx-status-indicator';
                progressIndicator.title = `Status: ${statusMap[statusText].name}`;
                for (let i = 0; i < 3; i++) {
                    const bar = document.createElement('div');
                    bar.className = 'ghx-status-indicator-bar';
                    progressIndicator.appendChild(bar);
                }
                if(typeSpan) typeSpan.insertAdjacentElement('afterend', progressIndicator);
            }
        }

        // Verwerk de overige velden en sorteer ze in de juiste sub-containers.
        elementsToProcess.forEach(field => {
            if (field.matches('span[data-tooltip^="Status:"]') || field.classList.contains('ghx-extra-field-seperator')) {
                return;
            }

            if (field.matches('span[data-tooltip^="Labels:"]')) {
                const labelContentEl = field.querySelector('.ghx-extra-field-content');
                if (labelContentEl && labelContentEl.textContent.trim() !== 'None') {
                    const labels = labelContentEl.textContent.trim().split(/,\s*/).filter(Boolean);
                    labels.forEach(label => {
                        const labelLower = label.toLowerCase();
                        const action = labelActions[labelLower];
                        let addToRightAsDefault = true;

                        if (action?.type === 'custom') {
                            let targetContainer = labelsSubContainer;
                            if (['small', 'medium', 'large'].includes(labelLower)) {
                                targetContainer = sizeIconsSubContainer;
                            }
                            if (action.handler(card, label, targetContainer) === false) {
                                addToRightAsDefault = false;
                            }
                        }

                        if (addToRightAsDefault) {
                            const lozenge = document.createElement('span');
                            lozenge.className = 'aui-label';
                            lozenge.textContent = label;
                            if (action?.type === 'style') Object.assign(lozenge.style, action.styles);
                            labelsSubContainer.appendChild(lozenge);
                        }
                    });
                }
            } else {
                otherFieldsSubContainer.appendChild(field.cloneNode(true));
            }
        });

        // Verwijder de oude containers en voeg de nieuwe, geordende containers toe.
        containersToRemove.forEach(container => container.remove());
        mainRow.querySelector('.ghx-end')?.remove();

        // Volgorde: 1. Labels -> 2. S/M/L Iconen -> 3. Titel Categorie -> 4. Andere velden
        if (labelsSubContainer.hasChildNodes()) masterRightContainer.appendChild(labelsSubContainer);
        if (sizeIconsSubContainer.hasChildNodes()) masterRightContainer.appendChild(sizeIconsSubContainer);
        if (titleCategorySubContainer.hasChildNodes()) masterRightContainer.appendChild(titleCategorySubContainer);
        if (otherFieldsSubContainer.hasChildNodes()) masterRightContainer.appendChild(otherFieldsSubContainer);

        if (masterRightContainer.hasChildNodes()) {
            mainRow.appendChild(masterRightContainer);
        }

        card.classList.add('ghx-layout-processed');
    }

    /**
     * Zoekt naar onverwerkte issue-kaarten en voert `processSingleCard` voor elke kaart uit.
     */
    function runProcessor() {
        const cards = document.querySelectorAll('.ghx-issue-compact:not(.ghx-layout-processed)');
        if (cards.length > 0) {
            cards.forEach(processSingleCard);
        }
    }

    // --- Script Initialisatie ---
    injectCustomStyles();

    const observer = new MutationObserver((mutations) => {
        clearTimeout(observer.timeout);
        observer.timeout = setTimeout(runProcessor, 100);
    });

    observer.observe(document.body, { childList: true, subtree: true });

    setTimeout(runProcessor, 500);

})();