Fast Monitoring GUI using GraphQL search, tabs, custom date range, and tag filtering with dynamic repo tracking.
// ==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);
})();