GitHub Issue Dashboard (Discontinued — Use GitHub Projects instead)

Fast Monitoring GUI using GraphQL search, tabs, custom date range, and tag filtering with dynamic repo tracking.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         GitHub Issue Dashboard (Discontinued — Use GitHub Projects instead)
// @namespace    http://tampermonkey.net/
// @version      0.3.5
// @description  Fast Monitoring GUI using GraphQL search, tabs, custom date range, and tag filtering with dynamic repo tracking.
// @author       joey&gemini
// @license      MIT
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';

    const CACHE_PREFIX = 'gh_graphql_dashboard_v20_';
    const CACHE_TTL = 1000 * 60 * 15; // Cache expiration set to 15 minutes

    // State Variables
    let githubToken = GM_getValue('GITHUB_PAT', '');
    let allIssues = [];
    let currentView = 'recent';
    let currentTagFilter = 'All';
    let daysMin = 0;
    let daysMax = 3;

    // --- Helper: Dynamically get the Repository from the current URL ---
    function getCurrentRepo() {
        const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
        if (match) {
            // Exclude GitHub system reserved paths to avoid false positives
            const invalidUsers = ['settings', 'notifications', 'explore', 'organizations', 'marketplace', 'sponsors', 'models', 'orgs', 'users'];
            if (!invalidUsers.includes(match[1])) {
                return `${match[1]}/${match[2]}`; // Returns e.g., "facebook/react"
            }
        }
        return null; // Not currently on a Repository page
    }

    // --- Helper: Get ISO format date string (X days ago) ---
    function getTargetDateISO(daysAgo) {
        const targetDate = new Date();
        targetDate.setDate(targetDate.getDate() - daysAgo);
        return targetDate.toISOString().split('T')[0];
    }

    // --- Helper: Format relative time ---
    function getRelativeTimeText(dateStr) {
        if (!dateStr) return 'Unknown';
        const diffMs = new Date() - new Date(dateStr);
        const diffMins = Math.floor(diffMs / 60000);
        if (diffMins < 1) return 'Just now';
        if (diffMins < 60) return `${diffMins} min(s)`;
        const diffHours = Math.floor(diffMins / 60);
        if (diffHours < 24) return `${diffHours} hr(s)`;
        const diffDays = Math.floor(diffHours / 24);
        if (diffDays < 30) return `${diffDays} day(s)`;
        return `${Math.floor(diffDays / 30)} mo(s)`;
    }

    // --- Core: Send authenticated GraphQL request ---
    async function fetchGraphQL(query) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.github.com/graphql',
                headers: {
                    'Authorization': `Bearer ${githubToken}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({ query }),
                onload: (res) => {
                    if (res.status === 401) {
                        reject(new Error('UNAUTHORIZED'));
                    } else if (res.status !== 200) {
                        reject(new Error(`HTTP ${res.status}`));
                    } else {
                        const json = JSON.parse(res.responseText);
                        if (json.errors) reject(new Error(json.errors[0].message));
                        else resolve(json.data);
                    }
                },
                onerror: () => reject(new Error('Network Error'))
            });
        });
    }

    // --- GraphQL Search Query ---
    async function searchIssues(viewType, minD, maxD, repo) {
        const dateOlder = getTargetDateISO(maxD);
        const dateNewer = getTargetDateISO(minD);
        const dateWeekly = getTargetDateISO(7);

        let searchQuery = viewType === 'recent'
            ? `repo:${repo} is:issue updated:${dateOlder}..${dateNewer}`
            : `repo:${repo} is:issue updated:<${dateWeekly} state:open`;

        // Add repo name to cache key to prevent cross-contamination between projects
        const safeRepoName = repo.replace('/', '_');
        const cacheKey = `${CACHE_PREFIX}${safeRepoName}_${viewType}` + (viewType === 'recent' ? `_${minD}_${maxD}` : '');
        const cached = localStorage.getItem(cacheKey);

        if (cached) {
            try {
                const parsed = JSON.parse(cached);
                if (Date.now() - parsed.timestamp < CACHE_TTL) return parsed.data;
            } catch (e) {}
        }

        const gqlQuery = `query {
          search(query: "${searchQuery}", type: ISSUE, first: 50) {
            nodes {
              ... on Issue {
                title url state updatedAt createdAt
                author { login }
                repository { nameWithOwner }
                labels(first: 5) { nodes { name } }
              }
            }
          }
        }`;

        const data = await fetchGraphQL(gqlQuery);
        const results = data.search.nodes || [];
        localStorage.setItem(cacheKey, JSON.stringify({ data: results, timestamp: Date.now() }));
        return results;
    }

    // --- UI: Render Token Setup Screen ---
    function renderTokenSetupUI(errorMsg = '') {
        const content = document.getElementById('issue-dashboard-content');
        content.innerHTML = `
            <div style="padding: 20px; color: #24292f;">
                <h3 style="margin-top: 0; margin-bottom: 12px; font-size: 16px; border-bottom: 1px solid #d0d7de; padding-bottom: 8px;">GitHub Token Authentication Required</h3>
                <p style="font-size: 13px; margin-bottom: 12px; color: #57606a;">To read project status, please provide a Personal Access Token (PAT).</p>
                <div style="margin-bottom: 16px;">
                    <a href="https://github.com/settings/tokens/new?description=Issues+Monitoring+Dashboard&scopes=repo" target="_blank" style="color: #0969da; text-decoration: none; font-size: 13px; font-weight: 600; border: 1px solid #d0d7de; padding: 6px 12px; border-radius: 4px; display: inline-block; background-color: #f6f8fa;">Generate Token</a>
                </div>
                <input type="password" id="issue-token-input" placeholder="Paste token here (ghp_...)" style="width: 100%; padding: 6px 8px; box-sizing: border-box; border: 1px solid #d0d7de; border-radius: 4px; font-size: 13px; margin-bottom: 12px;">
                <button id="issue-token-save-btn" style="width: 100%; padding: 6px 16px; background-color: #2da44e; color: #fff; border: 1px solid rgba(27,31,36,0.15); border-radius: 4px; font-weight: 600; cursor: pointer;">Save and Verify</button>
                ${errorMsg ? `<div style="color: #cf222e; font-size: 12px; margin-top: 10px; font-weight: 600;">[Error] ${errorMsg}</div>` : ''}
            </div>
        `;

        document.getElementById('issue-token-save-btn').addEventListener('click', async () => {
            const inputVal = document.getElementById('issue-token-input').value.trim();
            if (!inputVal) return;

            const btn = document.getElementById('issue-token-save-btn');
            btn.innerText = 'Verifying...';
            btn.disabled = true;

            const targetRepo = getCurrentRepo();
            if (!targetRepo) {
                renderTokenSetupUI('Please navigate to a Repository page before verifying.');
                return;
            }

            githubToken = inputVal;
            try {
                // Send a test request (using current page repo)
                await searchIssues('recent', 0, 0, targetRepo);
                GM_setValue('GITHUB_PAT', githubToken);
                updateDashboardData(currentView);
            } catch (err) {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI(err.message === 'UNAUTHORIZED' ? 'Token invalid or expired. Please regenerate.' : `Verification failed: ${err.message}`);
            }
        });
    }

    // --- UI: Render Dashboard Main Structure ---
    function renderDashboardHTML() {
        const dashboard = document.createElement('div');
        dashboard.id = 'issue-dashboard';
        dashboard.style.cssText = `
            position: fixed; bottom: 20px; right: 20px; width: 450px; height: 550px;
            background-color: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px;
            box-shadow: 0 8px 24px rgba(140,149,159,0.2); z-index: 10000; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;
            display: none; flex-direction: column; overflow: hidden; box-sizing: border-box;
            font-size: 13px; color: #24292f;
        `;

        const headerHTML = `
            <div style="background-color: #f6f8fa; color: #24292f; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-weight: 600; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
                <span id="issue-dashboard-header-title" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 350px;">Issues Monitoring</span>
                <button id="issue-dashboard-close" style="color: #57606a; background: none; border: none; font-weight: bold; cursor: pointer; padding: 0; flex-shrink: 0;">✕</button>
            </div>
            <div style="display: flex; background-color: #fff; border-bottom: 1px solid #d0d7de; flex-shrink: 0;">
                <button id="issue-dashboard-tab-recent" class="issue-tab issue-tab-active" style="flex: 1; padding: 10px; font-weight: 600; background: none; border: none; cursor: pointer; color: #57606a; border-bottom: 2px solid transparent;">Recent Updates</button>
                <button id="issue-dashboard-tab-weekly" class="issue-tab" style="flex: 1; padding: 10px; font-weight: 600; background: none; border: none; cursor: pointer; color: #57606a; border-bottom: 2px solid transparent;">Needs Follow-up</button>
            </div>
            <div style="padding: 10px 16px; background-color: #fff; border-bottom: 1px solid #d0d7de; flex-shrink: 0; display: flex; gap: 12px; align-items: center;">
                <div id="issue-days-container" style="display:flex; align-items:center; gap:4px;">
                    <label style="font-weight:600; font-size:12px; color:#57606a;">Days:</label>
                    <input type="number" id="issue-dashboard-days-min" value="${daysMin}" min="0" max="365" style="width: 40px; padding: 2px; text-align: center; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; color:#24292f; background-color: transparent;">
                    <span style="color:#57606a; font-weight:bold;">-</span>
                    <input type="number" id="issue-dashboard-days-max" value="${daysMax}" min="0" max="365" style="width: 40px; padding: 2px; text-align: center; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; color:#24292f; background-color: transparent;">
                </div>
                <div style="flex: 1; display: flex; align-items: center; gap: 6px;">
                    <label for="issue-dashboard-tag-filter" style="font-weight:600; font-size:12px; color:#57606a;">Tag:</label>
                    <select id="issue-dashboard-tag-filter" style="flex: 1; padding: 3px 6px; font-family: inherit; font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px; color:#24292f; background-color: transparent; cursor:pointer;"></select>
                </div>
            </div>
        `;

        const contentHTML = `
            <div id="issue-dashboard-content" style="flex: 1; overflow-y: auto; padding: 0; background-color: #fff;">
                <div style="padding: 16px; text-align: center; color: #57606a;">Loading Dashboard Data...</div>
            </div>
            <div style="padding: 8px 16px; background-color: #f6f8fa; border-top: 1px solid #d0d7de; flex-shrink: 0; display: flex; justify-content: flex-start; align-items: center;">
                <button id="issue-dashboard-refresh" title="Force refresh data" style="background: none; border: none; cursor: pointer; padding: 0; font-size: 18px; color: #57606a; font-weight: bold; transition: transform 0.4s ease, color 0.2s ease; display: flex; align-items: center;">⟲</button>
                <span style="font-size: 11px; color: #8c959f; margin-left: 8px;">Auto-caches for 15 mins</span>
            </div>
        `;

        dashboard.innerHTML = headerHTML + contentHTML;
        document.body.appendChild(dashboard);

        const styles = document.createElement('style');
        styles.innerHTML = `
            .issue-tab-active { color: #24292f !important; border-bottom: 2px solid #fd8c73 !important; }
            .issue-item { border-bottom: 1px solid #d0d7de; padding: 12px 16px; display: flex; flex-direction: column; transition: background-color 0.2s; }
            .issue-item:hover { background-color: #f6f8fa; }
            .issue-label { display:inline-block; margin-right:6px; margin-top:6px; padding:2px 8px; background-color:#ddf4ff; color:#0969da; border-radius:2em; font-size:11px; font-weight:500; }
            input[type=number]::-webkit-inner-spin-button { opacity: 1; }
            #issue-dashboard-refresh:hover { color: #0969da !important; }
        `;
        document.head.appendChild(styles);
    }

    // --- UI: Render toggle floating button ---
    function renderToggleButton() {
        const button = document.createElement('button');
        button.id = 'issue-dashboard-toggle';
        button.innerText = 'Dashboard';
        button.style.cssText = `
            position: fixed; bottom: 30px; right: 30px; padding: 8px 16px;
            background-color: #24292f; color: #fff; border: 1px solid rgba(27,31,36,0.15); border-radius: 6px;
            box-shadow: 0 3px 6px rgba(140,149,159,0.15); font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; font-size: 13px; font-weight: 600;
            cursor: pointer; z-index: 10001; transition: background-color 0.2s;
        `;
        button.onmouseover = () => button.style.backgroundColor = '#424a53';
        button.onmouseout = () => button.style.backgroundColor = '#24292f';
        document.body.appendChild(button);
    }

    // --- UI: Generate Tag dropdown menu ---
    function populateTagDropdown(issues) {
        const dropdown = document.getElementById('issue-dashboard-tag-filter');
        const tags = new Set();
        issues.forEach(issue => {
            issue.labels?.nodes?.forEach(label => tags.add(label.name));
        });

        const sortedTags = Array.from(tags).sort();
        let optionsHTML = '<option value="All">All Tags</option>';
        sortedTags.forEach(tag => {
            optionsHTML += `<option value="${tag}" ${tag === currentTagFilter ? 'selected' : ''}>${tag}</option>`;
        });
        dropdown.innerHTML = optionsHTML;
    }

    // --- UI: Update list content ---
    function updateDashboardContentList(issues) {
        const content = document.getElementById('issue-dashboard-content');
        let filteredIssues = issues;

        if (currentTagFilter !== 'All') {
            filteredIssues = issues.filter(issue =>
                issue.labels?.nodes?.some(label => label.name === currentTagFilter)
            );
        }

        if (filteredIssues.length === 0) {
            content.innerHTML = `
                <div style="text-align: center; color: #57606a; padding-top: 30px;">
                    <div style="margin-bottom: 12px;">No matching issues found for these criteria.</div>
                    <button id="issue-reset-token-btn" style="background: none; border: none; padding: 0; color: #0969da; cursor: pointer; text-decoration: underline; font-size: 12px;">Change Token?</button>
                </div>
            `;

            document.getElementById('issue-reset-token-btn').addEventListener('click', () => {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI('Please enter a new Token.');
            });
            return;
        }

        let issuesHTML = filteredIssues.map(issue => {
            const timeStr = currentView === 'recent' ? issue.updatedAt : issue.createdAt;
            const author = issue.author?.login || 'Unknown';
            const labels = issue.labels?.nodes?.map(l => `<span class="issue-label">${l.name}</span>`).join('') || '';

            return `
                <div class="issue-item">
                    <a href="${issue.url}" target="_blank" style="text-decoration: none; color: #0969da; font-weight: 600; font-size: 14px; line-height: 1.5;">${issue.title} <span style="color:#57606a; font-weight:normal;">#${issue.url.split('/').pop()}</span></a>
                    <div style="font-size: 12px; color: #57606a; margin-top: 4px;">By @${author} • ${getRelativeTimeText(timeStr)}</div>
                    <div>${labels}</div>
                </div>
            `;
        }).join('\n');

        content.innerHTML = issuesHTML;
    }

    // --- Data Flow: Fetch and Update Status ---
    async function updateDashboardData(viewType) {
        currentView = viewType;
        const daysContainer = document.getElementById('issue-days-container');
        const content = document.getElementById('issue-dashboard-content');
        const headerTitle = document.getElementById('issue-dashboard-header-title');

        daysContainer.style.display = viewType === 'recent' ? 'flex' : 'none';

        // Dynamically fetch current repository
        const targetRepo = getCurrentRepo();

        if (targetRepo) {
            headerTitle.innerText = `Issues (${targetRepo})`;
        } else {
            headerTitle.innerText = `Issues Monitoring`;
            content.innerHTML = '<div style="text-align:center; padding-top:40px; color:#57606a; font-weight:600;">Please navigate to a repository page to view its issues.</div>';
            return; // Stop further fetching
        }

        if (!githubToken) {
            renderTokenSetupUI();
            return;
        }

        content.innerHTML = '<div style="text-align:center; padding-top:30px; color:#57606a;">Loading data...</div>';

        try {
            allIssues = await searchIssues(viewType, daysMin, daysMax, targetRepo);
            populateTagDropdown(allIssues);
            updateDashboardContentList(allIssues);
        } catch (err) {
            if (err.message === 'UNAUTHORIZED') {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI('Saved Token is invalid or has insufficient permissions. Please reconfigure.');
            } else {
                content.innerHTML = `<div style="text-align: center; color: #cf222e; padding-top: 30px; font-weight: 600;">Failed to load data.<br/><span style="font-weight:normal; font-size:12px;">Error message: ${err.message}</span></div>`;
            }
        }
    }

    // --- Event: Force refresh data ---
    function handleForceRefresh() {
        if (!githubToken) return;

        const targetRepo = getCurrentRepo();
        if (!targetRepo) return;

        const btn = document.getElementById('issue-dashboard-refresh');
        btn.style.transform = 'rotate(-360deg)';
        setTimeout(() => {
            btn.style.transition = 'none';
            btn.style.transform = 'rotate(0deg)';
            setTimeout(() => btn.style.transition = 'transform 0.4s ease, color 0.2s ease', 10);
        }, 400);

        const safeRepoName = targetRepo.replace('/', '_');
        const cacheKey = `${CACHE_PREFIX}${safeRepoName}_${currentView}` + (currentView === 'recent' ? `_${daysMin}_${daysMax}` : '');
        localStorage.removeItem(cacheKey);

        updateDashboardData(currentView);
    }

    // --- UI: Toggle panel logic ---
    function toggleDashboard() {
        const dashboard = document.getElementById('issue-dashboard');
        const toggleBtn = document.getElementById('issue-dashboard-toggle');

        if (dashboard.style.display === 'none') {
            dashboard.style.display = 'flex';
            toggleBtn.style.display = 'none';
            updateDashboardData('recent'); // Re-check URL and status on every open
        } else {
            dashboard.style.display = 'none';
            toggleBtn.style.display = 'block';
        }
    }

    // --- UI: Switch tabs ---
    function switchTab(viewType) {
        document.querySelectorAll('.issue-tab').forEach(t => t.classList.remove('issue-tab-active'));
        if (viewType === 'recent') {
            document.getElementById('issue-dashboard-tab-recent').classList.add('issue-tab-active');
        } else {
            document.getElementById('issue-dashboard-tab-weekly').classList.add('issue-tab-active');
        }
        currentTagFilter = 'All';
        updateDashboardData(viewType);
    }

    // --- UI: Handle day range changes ---
    function handleDateRangeChange() {
        const inputMin = document.getElementById('issue-dashboard-days-min');
        const inputMax = document.getElementById('issue-dashboard-days-max');

        let valMin = parseInt(inputMin.value, 10);
        let valMax = parseInt(inputMax.value, 10);

        if (isNaN(valMin) || valMin < 0) valMin = 0;
        if (isNaN(valMax) || valMax < 0) valMax = 0;

        if (valMax < valMin) {
            valMax = valMin;
            inputMax.value = valMax;
        }

        daysMin = valMin;
        daysMax = valMax;
        updateDashboardData('recent');
    }

    // --- Initialization ---
    function init() {
        if (!document.getElementById('issue-dashboard-toggle')) {
            renderToggleButton();
            renderDashboardHTML();

            document.getElementById('issue-dashboard-toggle').addEventListener('click', toggleDashboard);
            document.getElementById('issue-dashboard-close').addEventListener('click', toggleDashboard);
            document.getElementById('issue-dashboard-refresh').addEventListener('click', handleForceRefresh);
            document.getElementById('issue-dashboard-tab-recent').addEventListener('click', () => switchTab('recent'));
            document.getElementById('issue-dashboard-tab-weekly').addEventListener('click', () => switchTab('weekly'));

            document.getElementById('issue-dashboard-tag-filter').addEventListener('change', (e) => {
                currentTagFilter = e.target.value;
                updateDashboardContentList(allIssues);
            });

            document.getElementById('issue-dashboard-days-min').addEventListener('change', handleDateRangeChange);
            document.getElementById('issue-dashboard-days-max').addEventListener('change', handleDateRangeChange);
        }

        // Automatically update data if dashboard is open during SPA navigation
        const dashboard = document.getElementById('issue-dashboard');
        if (dashboard && dashboard.style.display !== 'none') {
            updateDashboardData(currentView);
        }
    }

    init();

    // Listen for GitHub SPA page transition events (Turbo/Pjax)
    document.addEventListener('turbo:load', init);
    // Compatibility for older GitHub page transition mechanisms
    document.addEventListener('pjax:end', init);

})();