/** * Kanbante — Triage session (issue #11). * * One backlog card at a time, decided from the keyboard: * 1-5 move to a column (5 = done, closes), L labels panel, * A assign me, X close, Enter next (skip), Z undo last move. * Decisions hit the same APIs as the board (/api/move, label * toggles, assign-self) — the queue lives entirely client-side. */ (function () { var main = document.querySelector('.triage-main'); if (!main) return; var repo = main.getAttribute('data-repo'); var parts = repo.split('/'); var owner = parts[0], name = parts.slice(1).join('/'); var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; var queue = JSON.parse(document.getElementById('triage-queue').textContent || '[]'); var idx = 0; var history = []; // {number, to} for undoable moves var counts = {}; // decision tally for the summary var busy = false; var el = function (id) { return document.getElementById(id); }; var cardEl = el('triage-card'), doneEl = el('triage-done'); var statusEl = el('triage-status'), labelsPanel = el('triage-labels'); function current() { return queue[idx] || null; } function status(msg, isError) { statusEl.textContent = msg || ''; statusEl.style.color = isError ? '#ff7b72' : ''; } function render() { var card = current(); var total = queue.length; el('triage-bar').style.width = total ? Math.round(100 * idx / total) + '%' : '100%'; el('triage-progress-label').textContent = total ? 'Carte ' + Math.min(idx + 1, total) + ' / ' + total : '0 carte'; if (!card) { cardEl.hidden = true; el('triage-keys').hidden = true; doneEl.hidden = false; var summary = el('triage-summary'); summary.innerHTML = ''; var labels = { todo: '📋 À faire', in_progress: '🚧 En cours', qa: '🧪 QA', review: '👀 Révision', done: '✅ Terminé / fermée', skip: '⏭️ Passées (restent au backlog)', }; Object.keys(labels).forEach(function (key) { if (counts[key]) { var li = document.createElement('li'); li.textContent = labels[key] + ' : ' + counts[key]; summary.appendChild(li); } }); if (!Object.keys(counts).length) { var li = document.createElement('li'); li.textContent = 'Aucune décision prise.'; summary.appendChild(li); } return; } cardEl.hidden = false; doneEl.hidden = true; el('tc-num').textContent = '#' + card.number; el('tc-date').textContent = card.created_at ? 'créée le ' + card.created_at + (card.age_days != null ? ' · ⏳ ' + card.age_days + ' j' : '') : ''; el('tc-link').href = card.html_url || '#'; el('tc-title').textContent = card.title; el('tc-excerpt').textContent = card.excerpt || '(aucune description)'; var labelsBox = el('tc-labels'); labelsBox.innerHTML = ''; (card.labels || []).forEach(function (l) { var chip = document.createElement('span'); chip.className = 'label-tag'; chip.style.background = '#' + l.color + '30'; chip.style.color = '#' + l.color; chip.textContent = l.name; labelsBox.appendChild(chip); }); el('tc-assign').textContent = (card.assignees && card.assignees.length) ? '👤 Assignée à : ' + card.assignees.join(', ') : ''; status(''); } function post(url, fields) { var data = new FormData(); Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); }); data.append('csrf_token', csrf); return fetch(url, { method: 'POST', body: data }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (body) { if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status)); return body; }); }); } function advance(kind) { counts[kind] = (counts[kind] || 0) + 1; idx += 1; render(); } function decide(toColumn) { var card = current(); if (!card || busy) return; busy = true; status('⏳ Envoi…'); post('/api/move', { repo_full_name: repo, issue_number: card.number, from_column: 'backlog', to_column: toColumn, from_order: '', to_order: '', }).then(function () { history.push({ number: card.number, to: toColumn }); busy = false; advance(toColumn); }).catch(function (err) { busy = false; status('❌ ' + err.message + ' — réessaie.', true); }); } function undo() { var last = history.pop(); var card = current(); if (!last || busy) return; // The undone card is the one just before the current index. var target = queue[idx - 1]; if (!target || target.number !== last.number) return; busy = true; status('⏳ Annulation…'); post('/api/move', { repo_full_name: repo, issue_number: last.number, from_column: last.to, to_column: 'backlog', from_order: '', to_order: '', }).then(function () { counts[last.to] = Math.max(0, (counts[last.to] || 1) - 1); if (!counts[last.to]) delete counts[last.to]; idx -= 1; busy = false; render(); }).catch(function (err) { busy = false; history.push(last); status('❌ Annulation impossible : ' + err.message, true); }); void card; } function assignSelf() { var card = current(); if (!card || busy) return; busy = true; status('⏳ Assignation…'); post('/api/issues/' + owner + '/' + name + '/' + card.number + '/assign-self', {}) .then(function (body) { card.assignees = body.assignees || card.assignees; busy = false; render(); status('👤 Assignée à : ' + (body.assignees || []).join(', ')); }) .catch(function (err) { busy = false; status('❌ ' + err.message, true); }); } function openLabels() { var card = current(); if (!card) return; labelsPanel.hidden = false; var list = el('triage-labels-list'); list.textContent = 'Chargement…'; fetch('/api/repos/' + owner + '/' + name + '/labels') .then(function (r) { return r.json(); }) .then(function (data) { list.innerHTML = ''; var mine = (card.labels || []).map(function (l) { return l.name; }); (data.labels || []).forEach(function (label) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'triage-label-chip' + (mine.includes(label.name) ? ' on' : ''); btn.textContent = (mine.includes(label.name) ? '✓ ' : '') + label.name; btn.addEventListener('click', function () { var action = mine.includes(label.name) ? 'remove' : 'add'; post('/api/issues/' + owner + '/' + name + '/' + card.number + '/labels', { label_id: label.id, action: action }) .then(function (body) { card.labels = body.labels || []; render(); openLabels(); }) .catch(function (err) { status('❌ ' + err.message, true); }); }); list.appendChild(btn); }); if (!(data.labels || []).length) list.textContent = 'Aucune étiquette dans ce dépôt.'; }) .catch(function () { list.textContent = 'Échec du chargement des étiquettes.'; }); } function closeLabels() { labelsPanel.hidden = true; } document.querySelectorAll('.triage-keys button').forEach(function (btn) { btn.addEventListener('click', function () { var act = btn.getAttribute('data-act'); if (act === 'move') decide(btn.getAttribute('data-col')); else if (act === 'close') decide('done'); else if (act === 'next') { if (current()) advance('skip'); } else if (act === 'undo') undo(); else if (act === 'assign') assignSelf(); else if (act === 'labels') { labelsPanel.hidden ? openLabels() : closeLabels(); } }); }); el('triage-labels-close').addEventListener('click', closeLabels); document.addEventListener('keydown', function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; var tag = (document.activeElement || {}).tagName || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (!labelsPanel.hidden) { if (e.key === 'Escape' || e.key.toLowerCase() === 'l') { e.preventDefault(); closeLabels(); } return; } var colByKey = { '1': 'todo', '2': 'in_progress', '3': 'qa', '4': 'review', '5': 'done' }; if (colByKey[e.key]) { e.preventDefault(); decide(colByKey[e.key]); } else if (e.key === 'Enter') { e.preventDefault(); if (current()) advance('skip'); } else if (e.key.toLowerCase() === 'x') { e.preventDefault(); decide('done'); } else if (e.key.toLowerCase() === 'l') { e.preventDefault(); openLabels(); } else if (e.key.toLowerCase() === 'a') { e.preventDefault(); assignSelf(); } else if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); } }); render(); })();