feat(triage): mode triage — vider le backlog au clavier (issue #11)

/triage (sélecteur par taille de backlog) + /triage/{o}/{r}
(session plein cadre, file embarquée): 1-5 colonnes, X fermer,
L étiquettes, A s'assigner (nouvel endpoint assign-self +
client.set_issue_assignees, idempotent), Entrée suivante, Z annuler.
Progression + résumé de session; décisions via /api/move et les
API existantes. Entrées: board + palette.
This commit is contained in:
Spark
2026-10-11 13:17:21 -04:00
parent 25040f974c
commit 8a32d080b5
8 changed files with 539 additions and 0 deletions
+54
View File
@@ -1026,3 +1026,57 @@ footer {
color: var(--text-3); font-size: 0.78rem; text-align: right;
}
.palette-trigger { cursor: pointer; font: inherit; }
/* ── Mode Triage (0.10.0, #11) ── */
.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; }
.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; }
.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; }
.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; }
.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.triage-card {
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px; padding: 26px 28px; min-height: 220px;
}
.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; }
.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); }
.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; }
.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; }
.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; }
.triage-card-assign { color: var(--text-3); }
.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.triage-keys button {
background: var(--surface-2, #182236); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 10px;
padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer;
}
.triage-keys button:hover { border-color: var(--accent, #58a6ff); }
.triage-keys kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52);
border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px;
}
.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); }
.triage-labels {
margin-top: 14px; background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px;
}
.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; }
.triage-label-chip {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 99px;
padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
}
.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); }
.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; }
.triage-summary { line-height: 1.9; }
.triage-projects { display: grid; gap: 10px; }
.triage-project {
display: flex; justify-content: space-between; align-items: center;
background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a);
border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none;
}
.triage-project:hover { border-color: var(--accent, #58a6ff); }
.triage-project-count { color: var(--text-3); }
+1
View File
@@ -17,6 +17,7 @@
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
{ icon: '📥', label: 'Mode Triage — vider un backlog au clavier', href: '/triage' },
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
+236
View File
@@ -0,0 +1,236 @@
/**
* 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();
})();