diff --git a/index.html b/index.html index 4552599..360bee7 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@ + @@ -426,6 +427,12 @@

Done

+
+ +
diff --git a/src/kanban.js b/src/kanban.js index 5f284f1..a5e97d1 100644 --- a/src/kanban.js +++ b/src/kanban.js @@ -219,6 +219,21 @@ document.addEventListener('DOMContentLoaded', function() { } } }); + + updateArchiveAllButton(); + } + + // Show the Done column's "Archive all" button only when signed in and there + // is at least one GitHub issue card available to archive. + function updateArchiveAllButton() { + const btn = document.getElementById('archive-all-done-btn'); + const doneColumn = document.getElementById('done'); + if (!btn || !doneColumn) { + return; + } + const authed = Boolean(window.GitHub?.isGitHubAuthed?.()); + const issueCount = doneColumn.querySelectorAll('.bg-white.border[data-issue-number]').length; + btn.classList.toggle('hidden', !(authed && issueCount > 0)); } // Expose updateColumnCounts globally for modal use @@ -310,6 +325,28 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Archive every GitHub issue currently in the Done column at once. + document.addEventListener('click', function(e) { + if (!e.target.closest('#archive-all-done-btn')) { + return; + } + const doneColumn = document.getElementById('done'); + if (!doneColumn) { + return; + } + const issueCards = Array.from(doneColumn.querySelectorAll('.bg-white.border[data-issue-number]')); + if (issueCards.length === 0) { + return; + } + const plural = issueCards.length === 1 ? 'issue' : 'issues'; + if (!window.confirm(`Archive all ${issueCards.length} ${plural} in Done?`)) { + return; + } + issueCards.forEach(card => { + window.GitHub.archiveGitHubIssue(card.getAttribute('data-issue-number'), card); + }); + }); + // Add click to open issue modal functionality document.addEventListener('click', function(e) { // Only handle clicks on issue cards with GitHub issue numbers @@ -476,6 +513,7 @@ document.addEventListener('DOMContentLoaded', function() { const testAPI = { createTaskElement, updateColumnCounts, + updateArchiveAllButton, hideModal, addTaskBtn, addTaskModal, diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..ad49e61 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,17 @@ +// Quiets Dashban's routine console output. +// +// The app modules log a lot of informational/lifecycle detail via console.log. +// Loading this script first (before the other src modules) replaces console.log +// with a version that stays silent unless debugging is explicitly enabled by +// setting `window.DASHBAN_DEBUG = true` (e.g. from devtools). console.error and +// console.warn are left untouched, so genuine problems still surface. +(function () { + 'use strict'; + + const original = console.log.bind(console); + console.log = function () { + if (globalThis.DASHBAN_DEBUG) { + original.apply(null, arguments); + } + }; +})(); diff --git a/src/status-cards.js b/src/status-cards.js index 3bf4b5c..a057843 100644 --- a/src/status-cards.js +++ b/src/status-cards.js @@ -217,12 +217,25 @@ document.addEventListener('DOMContentLoaded', function() { } async function fetchCoverageStatus() { + // Remember which repo this request is for. If the user switches repos + // while the request is in flight, a slow/late response must not overwrite + // the now-current repo's coverage (switching to a repo with no coverage + // and back was leaving dashban stuck on "unknown"). + const { OWNER, REPO } = getCurrentRepoConfig(); + const isStale = () => { + const current = getCurrentRepoConfig(); + return current.OWNER !== OWNER || current.REPO !== REPO; + }; + try { // The coverage badge URL already carries a query (?branch=main), so // join the cache-buster with & rather than ?. const badgeUrl = `${buildBadgeUrl('coverage')}&t=${Date.now()}`; const svgText = await fetch(badgeUrl).then(r => r.text()); + if (isStale()) { + return; + } const coverage = parseCoverageFromSVG(svgText); const coverageData = { @@ -234,7 +247,10 @@ document.addEventListener('DOMContentLoaded', function() { updateCoverageStatusUI(coverageData); } catch (error) { console.error('Error fetching coverage status:', error); - + + if (isStale()) { + return; + } const fallbackData = { coverage: 'unknown', updatedAt: new Date(), diff --git a/tests/kanban.test.js b/tests/kanban.test.js index e5f3945..ad047c9 100644 --- a/tests/kanban.test.js +++ b/tests/kanban.test.js @@ -868,6 +868,142 @@ describe('Kanban Board Core Functionality', () => { }); }); + describe('archive all in the Done column', () => { + function addArchiveAllButton() { + const btn = document.createElement('button'); + btn.id = 'archive-all-done-btn'; + btn.className = 'hidden'; + document.body.appendChild(btn); + return btn; + } + + function addDoneIssueCard(number) { + const card = document.createElement('div'); + card.className = 'bg-white border'; + card.setAttribute('data-issue-number', String(number)); + document.getElementById('done').appendChild(card); + return card; + } + + beforeEach(() => { + global.window.GitHub.githubAuth = { isAuthenticated: true, mode: 'clerk', user: { login: 'octocat' } }; + global.window.GitHub.archiveGitHubIssue = jest.fn(); + global.window.confirm = jest.fn(() => true); + }); + + describe('updateArchiveAllButton', () => { + test('shows the button when signed in and Done has issue cards', () => { + const btn = addArchiveAllButton(); + addDoneIssueCard(1); + api.updateArchiveAllButton(); + expect(btn.classList.contains('hidden')).toBe(false); + }); + + test('hides the button when Done has no issue cards', () => { + const btn = addArchiveAllButton(); + api.updateArchiveAllButton(); + expect(btn.classList.contains('hidden')).toBe(true); + }); + + test('hides the button when not signed in', () => { + const btn = addArchiveAllButton(); + addDoneIssueCard(1); + global.window.GitHub.githubAuth = { isAuthenticated: false, mode: null, user: null }; + api.updateArchiveAllButton(); + expect(btn.classList.contains('hidden')).toBe(true); + }); + + test('returns quietly when the button is absent', () => { + expect(() => api.updateArchiveAllButton()).not.toThrow(); + }); + + test('returns quietly when the Done column is absent', () => { + const btn = addArchiveAllButton(); + document.getElementById('done').remove(); + expect(() => api.updateArchiveAllButton()).not.toThrow(); + expect(btn.classList.contains('hidden')).toBe(true); + }); + + test('treats a missing GitHub integration as signed out', () => { + const btn = addArchiveAllButton(); + addDoneIssueCard(1); + const saved = global.window.GitHub; + delete global.window.GitHub; + try { + api.updateArchiveAllButton(); + expect(btn.classList.contains('hidden')).toBe(true); + } finally { + global.window.GitHub = saved; + } + }); + + test('treats GitHub without isGitHubAuthed as signed out', () => { + const btn = addArchiveAllButton(); + addDoneIssueCard(1); + const saved = global.window.GitHub; + global.window.GitHub = {}; + try { + api.updateArchiveAllButton(); + expect(btn.classList.contains('hidden')).toBe(true); + } finally { + global.window.GitHub = saved; + } + }); + }); + + describe('archive-all click handler', () => { + test('archives every issue card in Done after confirmation', () => { + addArchiveAllButton(); + const c1 = addDoneIssueCard(1); + const c2 = addDoneIssueCard(2); + + document.getElementById('archive-all-done-btn').dispatchEvent(new Event('click', { bubbles: true })); + + expect(global.window.confirm).toHaveBeenCalledWith('Archive all 2 issues in Done?'); + expect(global.window.GitHub.archiveGitHubIssue).toHaveBeenCalledWith('1', c1); + expect(global.window.GitHub.archiveGitHubIssue).toHaveBeenCalledWith('2', c2); + }); + + test('uses the singular noun for a single issue', () => { + addArchiveAllButton(); + addDoneIssueCard(7); + + document.getElementById('archive-all-done-btn').dispatchEvent(new Event('click', { bubbles: true })); + + expect(global.window.confirm).toHaveBeenCalledWith('Archive all 1 issue in Done?'); + expect(global.window.GitHub.archiveGitHubIssue).toHaveBeenCalledWith('7', expect.any(Object)); + }); + + test('does nothing when the user cancels', () => { + global.window.confirm = jest.fn(() => false); + addArchiveAllButton(); + addDoneIssueCard(1); + + document.getElementById('archive-all-done-btn').dispatchEvent(new Event('click', { bubbles: true })); + + expect(global.window.GitHub.archiveGitHubIssue).not.toHaveBeenCalled(); + }); + + test('does not prompt when Done has no issue cards', () => { + addArchiveAllButton(); + + document.getElementById('archive-all-done-btn').dispatchEvent(new Event('click', { bubbles: true })); + + expect(global.window.confirm).not.toHaveBeenCalled(); + expect(global.window.GitHub.archiveGitHubIssue).not.toHaveBeenCalled(); + }); + + test('does nothing when the Done column is missing', () => { + addArchiveAllButton(); + document.getElementById('done').remove(); + + document.getElementById('archive-all-done-btn').dispatchEvent(new Event('click', { bubbles: true })); + + expect(global.window.confirm).not.toHaveBeenCalled(); + }); + }); + }); + describe('double-click edit functionality', () => { test('should handle double-click events on tasks', () => { const taskElement = document.createElement('div'); diff --git a/tests/logger.test.js b/tests/logger.test.js new file mode 100644 index 0000000..06f5857 --- /dev/null +++ b/tests/logger.test.js @@ -0,0 +1,59 @@ +/** + * Tests for src/logger.js — the console.log gate that keeps the browser console + * quiet unless window.DASHBAN_DEBUG is set. + */ +describe('logger (console.log gate)', () => { + let originalLog; + let originalError; + let originalWarn; + + beforeEach(() => { + jest.resetModules(); + originalLog = console.log; + originalError = console.error; + originalWarn = console.warn; + delete globalThis.DASHBAN_DEBUG; + }); + + afterEach(() => { + console.log = originalLog; + console.error = originalError; + console.warn = originalWarn; + delete globalThis.DASHBAN_DEBUG; + }); + + test('suppresses console.log by default (debugging off)', () => { + const sink = jest.fn(); + console.log = sink; // stand in for the real console.log + require('../src/logger.js'); // wraps console.log, capturing `sink` as the original + + console.log('hidden'); + + expect(sink).not.toHaveBeenCalled(); + }); + + test('forwards console.log when DASHBAN_DEBUG is enabled', () => { + const sink = jest.fn(); + console.log = sink; + require('../src/logger.js'); + + globalThis.DASHBAN_DEBUG = true; + console.log('shown', 1); + + expect(sink).toHaveBeenCalledWith('shown', 1); + }); + + test('leaves console.error and console.warn untouched', () => { + const errSink = jest.fn(); + const warnSink = jest.fn(); + console.error = errSink; + console.warn = warnSink; + require('../src/logger.js'); + + console.error('boom'); + console.warn('careful'); + + expect(errSink).toHaveBeenCalledWith('boom'); + expect(warnSink).toHaveBeenCalledWith('careful'); + }); +}); diff --git a/tests/status-cards.test.js b/tests/status-cards.test.js index 7778198..30b7ac3 100644 --- a/tests/status-cards.test.js +++ b/tests/status-cards.test.js @@ -966,11 +966,40 @@ describe('Status Cards Functions', () => { test('should handle coverage fetch errors', async () => { global.fetch.mockRejectedValue(new Error('Coverage fetch error')); - + await statusAPI.fetchCoverageStatus(); - + expect(console.error).toHaveBeenCalledWith('Error fetching coverage status:', expect.any(Error)); }); + + test('drops a stale coverage response when the repo changed mid-request', async () => { + setupGitHubAuth('super3', 'dashban'); + const coverageEl = document.querySelector('[data-coverage-status]'); + coverageEl.innerHTML = 'SENTINEL'; + // The user switches repos while the request is in flight. + global.fetch.mockImplementation(async () => { + global.window.GitHubAuth.GITHUB_CONFIG.repo = 'other-repo'; + return { text: async () => '85%' }; + }); + + await statusAPI.fetchCoverageStatus(); + + expect(coverageEl.innerHTML).toBe('SENTINEL'); + }); + + test('drops a stale coverage error when the repo changed mid-request', async () => { + setupGitHubAuth('super3', 'dashban'); + const coverageEl = document.querySelector('[data-coverage-status]'); + coverageEl.innerHTML = 'SENTINEL'; + global.fetch.mockImplementation(async () => { + global.window.GitHubAuth.GITHUB_CONFIG.repo = 'other-repo'; + throw new Error('network'); + }); + + await statusAPI.fetchCoverageStatus(); + + expect(coverageEl.innerHTML).toBe('SENTINEL'); + }); }); describe('refreshAllStatuses', () => {