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:
@@ -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); }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user