trackops 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -0
- package/lib/init.js +2 -2
- package/lib/locale.js +41 -17
- package/lib/opera-bootstrap.js +68 -7
- package/lib/opera.js +10 -2
- package/lib/registry.js +18 -0
- package/lib/server.js +312 -207
- package/locales/en.json +4 -0
- package/locales/es.json +4 -0
- package/package.json +1 -1
- package/skills/trackops/locales/en/references/activation.md +15 -0
- package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
- package/skills/trackops/references/activation.md +15 -0
- package/skills/trackops/references/troubleshooting.md +12 -0
- package/skills/trackops/skill.json +2 -2
- package/ui/css/base.css +19 -1
- package/ui/css/charts.css +106 -8
- package/ui/css/components.css +554 -17
- package/ui/css/onboarding.css +133 -0
- package/ui/css/panels.css +345 -406
- package/ui/css/terminal.css +125 -0
- package/ui/css/timeline.css +58 -0
- package/ui/css/tokens.css +170 -113
- package/ui/index.html +3 -0
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +82 -57
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +20 -3
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +197 -247
- package/ui/js/views/sidebar.js +37 -31
- package/ui/js/views/tasks.js +218 -101
- package/ui/js/views/timeline.js +265 -0
- package/ui/js/views/topbar.js +94 -107
- package/ui/app.js +0 -950
- package/ui/js/views/insights.js +0 -340
- package/ui/js/views/overview.js +0 -369
- package/ui/styles.css +0 -688
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keyboard.js — Sistema de atajos de teclado global para TrackOps Dashboard
|
|
3
|
+
* Secuencias de 2 teclas estilo Vim/GitHub (g d = go dashboard).
|
|
4
|
+
* No activo cuando un input/textarea/select tiene foco.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as router from './router.js';
|
|
8
|
+
import * as state from './state.js';
|
|
9
|
+
import { icon } from './icons.js';
|
|
10
|
+
|
|
11
|
+
/** @type {Map<string, {handler: Function, label: string, group: string}>} */
|
|
12
|
+
const _shortcuts = new Map();
|
|
13
|
+
|
|
14
|
+
/** Buffer para secuencias de 2 teclas */
|
|
15
|
+
let _pendingKey = '';
|
|
16
|
+
let _pendingTimer = null;
|
|
17
|
+
const SEQUENCE_TIMEOUT = 600; // ms para completar secuencia
|
|
18
|
+
|
|
19
|
+
/** Elemento del panel de ayuda */
|
|
20
|
+
let _helpVisible = false;
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────── REGISTRO ────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Registrar un atajo de teclado
|
|
26
|
+
* @param {string} keys — Tecla o secuencia ('g o', '/', '?', 'Escape')
|
|
27
|
+
* @param {Function} handler — Callback
|
|
28
|
+
* @param {string} label — Descripcion para el panel de ayuda
|
|
29
|
+
* @param {string} [group='General'] — Grupo en el panel de ayuda
|
|
30
|
+
*/
|
|
31
|
+
export function register(keys, handler, label, group = 'General') {
|
|
32
|
+
_shortcuts.set(keys, { handler, label, group });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────── DEFAULTS ────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function _registerDefaults() {
|
|
38
|
+
// Navegacion
|
|
39
|
+
register('g d', () => router.navigate('dashboard'), 'Go to Dashboard', 'Navigation');
|
|
40
|
+
register('g t', () => router.navigate('tasks'), 'Go to Tasks', 'Navigation');
|
|
41
|
+
register('g l', () => router.navigate('timeline'), 'Go to Timeline', 'Navigation');
|
|
42
|
+
register('g e', () => router.navigate('terminal'), 'Go to Terminal', 'Navigation');
|
|
43
|
+
register('g p', () => router.navigate('projects'), 'Go to Projects', 'Navigation');
|
|
44
|
+
register('g s', () => router.navigate('settings'), 'Go to Settings', 'Navigation');
|
|
45
|
+
|
|
46
|
+
// Acciones
|
|
47
|
+
register('/', () => _focusSearch(), 'Focus search', 'Actions');
|
|
48
|
+
register('r', () => _refreshState(), 'Refresh data', 'Actions');
|
|
49
|
+
register('Escape', () => _closeActiveOverlay(), 'Close overlay', 'Actions');
|
|
50
|
+
|
|
51
|
+
// Ayuda
|
|
52
|
+
register('?', () => toggleHelp(), 'Show shortcuts', 'Help');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────── HANDLER ─────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function _onKeyDown(e) {
|
|
58
|
+
// No interceptar si un input tiene foco
|
|
59
|
+
const tag = document.activeElement?.tagName?.toLowerCase();
|
|
60
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
61
|
+
// Solo Escape escapa de inputs
|
|
62
|
+
if (e.key === 'Escape') {
|
|
63
|
+
document.activeElement.blur();
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// No interceptar si hay modificadores (excepto Shift para ?)
|
|
70
|
+
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
71
|
+
|
|
72
|
+
const key = e.key;
|
|
73
|
+
|
|
74
|
+
// Buscar atajo directo (1 tecla)
|
|
75
|
+
if (!_pendingKey) {
|
|
76
|
+
const direct = _shortcuts.get(key);
|
|
77
|
+
if (direct) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
direct.handler();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Iniciar secuencia de 2 teclas
|
|
84
|
+
// Solo letras minusculas inician secuencias
|
|
85
|
+
if (/^[a-z]$/.test(key)) {
|
|
86
|
+
_pendingKey = key;
|
|
87
|
+
_pendingTimer = setTimeout(() => {
|
|
88
|
+
_pendingKey = '';
|
|
89
|
+
_pendingTimer = null;
|
|
90
|
+
}, SEQUENCE_TIMEOUT);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Completar secuencia de 2 teclas
|
|
97
|
+
clearTimeout(_pendingTimer);
|
|
98
|
+
const sequence = `${_pendingKey} ${key}`;
|
|
99
|
+
_pendingKey = '';
|
|
100
|
+
_pendingTimer = null;
|
|
101
|
+
|
|
102
|
+
const seq = _shortcuts.get(sequence);
|
|
103
|
+
if (seq) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
seq.handler();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────── ACCIONES ────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function _focusSearch() {
|
|
112
|
+
const input = document.querySelector('.topbar-search input, .topbar-search-input');
|
|
113
|
+
if (input) {
|
|
114
|
+
input.focus();
|
|
115
|
+
input.select();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _refreshState() {
|
|
120
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _closeActiveOverlay() {
|
|
124
|
+
// Cerrar help panel
|
|
125
|
+
if (_helpVisible) {
|
|
126
|
+
toggleHelp();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Cerrar modal
|
|
130
|
+
const modal = document.querySelector('.modal-overlay:not(.is-hidden)');
|
|
131
|
+
if (modal) {
|
|
132
|
+
modal.classList.add('is-hidden');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Cerrar console panel
|
|
136
|
+
const consolePanel = document.getElementById('console-panel');
|
|
137
|
+
if (consolePanel?.classList.contains('is-open')) {
|
|
138
|
+
consolePanel.classList.remove('is-open');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─────────────────────────────── HELP PANEL ──────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export function toggleHelp() {
|
|
145
|
+
_helpVisible = !_helpVisible;
|
|
146
|
+
let panel = document.getElementById('keyboard-help-panel');
|
|
147
|
+
|
|
148
|
+
if (!_helpVisible && panel) {
|
|
149
|
+
panel.remove();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (_helpVisible) {
|
|
154
|
+
panel = document.createElement('div');
|
|
155
|
+
panel.id = 'keyboard-help-panel';
|
|
156
|
+
panel.className = 'modal-overlay';
|
|
157
|
+
panel.setAttribute('role', 'dialog');
|
|
158
|
+
panel.setAttribute('aria-modal', 'true');
|
|
159
|
+
panel.setAttribute('aria-label', 'Keyboard shortcuts');
|
|
160
|
+
panel.addEventListener('click', (e) => {
|
|
161
|
+
if (e.target === panel) toggleHelp();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Agrupar shortcuts
|
|
165
|
+
const groups = new Map();
|
|
166
|
+
for (const [keys, { label, group }] of _shortcuts) {
|
|
167
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
168
|
+
groups.get(group).push({ keys, label });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let groupsHtml = '';
|
|
172
|
+
for (const [group, items] of groups) {
|
|
173
|
+
groupsHtml += `
|
|
174
|
+
<div class="kb-help-group">
|
|
175
|
+
<h3 class="kb-help-group-title">${group}</h3>
|
|
176
|
+
<div class="kb-help-items">
|
|
177
|
+
${items.map(({ keys, label }) => `
|
|
178
|
+
<div class="kb-help-item">
|
|
179
|
+
<kbd class="kb-help-key">${_formatKey(keys)}</kbd>
|
|
180
|
+
<span class="kb-help-label">${label}</span>
|
|
181
|
+
</div>
|
|
182
|
+
`).join('')}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
panel.innerHTML = `
|
|
189
|
+
<div class="modal" style="max-width:520px">
|
|
190
|
+
<div class="modal-header">
|
|
191
|
+
<h2 class="modal-title">${icon('keyboard', 18)} Keyboard shortcuts</h2>
|
|
192
|
+
<button class="modal-close" type="button" aria-label="Close" id="kb-help-close">×</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="modal-body" style="padding:var(--space-4) var(--space-6)">
|
|
195
|
+
${groupsHtml}
|
|
196
|
+
</div>
|
|
197
|
+
<div class="modal-footer" style="justify-content:center">
|
|
198
|
+
<p style="font-size:var(--text-xs);color:var(--text-muted)">Press <kbd class="kb-help-key">?</kbd> to toggle this panel</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
document.body.appendChild(panel);
|
|
204
|
+
panel.querySelector('#kb-help-close')?.addEventListener('click', () => toggleHelp());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _formatKey(keys) {
|
|
209
|
+
return keys.split(' ').map(k => {
|
|
210
|
+
if (k === 'Escape') return 'Esc';
|
|
211
|
+
if (k === '/') return '/';
|
|
212
|
+
if (k === '?') return '?';
|
|
213
|
+
return k.toUpperCase();
|
|
214
|
+
}).join(' <span style="opacity:0.4">then</span> ');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────────── INIT ────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
export function init() {
|
|
220
|
+
_registerDefaults();
|
|
221
|
+
document.addEventListener('keydown', _onKeyDown);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function destroy() {
|
|
225
|
+
document.removeEventListener('keydown', _onKeyDown);
|
|
226
|
+
_shortcuts.clear();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default { init, destroy, register, toggleHelp };
|
package/ui/js/onboarding.js
CHANGED
|
@@ -28,7 +28,7 @@ const STEPS = [
|
|
|
28
28
|
titleKey: 'ui.onboarding.nav.title',
|
|
29
29
|
descKey: 'ui.onboarding.nav.desc',
|
|
30
30
|
target: '#sidebar',
|
|
31
|
-
view: '
|
|
31
|
+
view: 'dashboard',
|
|
32
32
|
pos: 'right',
|
|
33
33
|
},
|
|
34
34
|
|
|
@@ -36,8 +36,8 @@ const STEPS = [
|
|
|
36
36
|
{
|
|
37
37
|
titleKey: 'ui.onboarding.kpi.title',
|
|
38
38
|
descKey: 'ui.onboarding.kpi.desc',
|
|
39
|
-
target: '.
|
|
40
|
-
view: '
|
|
39
|
+
target: '.grid-4',
|
|
40
|
+
view: 'dashboard',
|
|
41
41
|
pos: 'bottom',
|
|
42
42
|
},
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ const STEPS = [
|
|
|
46
46
|
titleKey: 'ui.onboarding.activity.title',
|
|
47
47
|
descKey: 'ui.onboarding.activity.desc',
|
|
48
48
|
target: '.chart-card',
|
|
49
|
-
view: '
|
|
49
|
+
view: 'dashboard',
|
|
50
50
|
pos: 'bottom',
|
|
51
51
|
},
|
|
52
52
|
|
|
@@ -55,71 +55,62 @@ const STEPS = [
|
|
|
55
55
|
titleKey: 'ui.onboarding.progress.title',
|
|
56
56
|
descKey: 'ui.onboarding.progress.desc',
|
|
57
57
|
target: '.donut-wrapper',
|
|
58
|
-
view: '
|
|
58
|
+
view: 'dashboard',
|
|
59
59
|
pos: 'left',
|
|
60
60
|
},
|
|
61
61
|
|
|
62
|
-
// 5 —
|
|
63
|
-
{
|
|
64
|
-
titleKey: 'ui.onboarding.time.title',
|
|
65
|
-
descKey: 'ui.onboarding.time.desc',
|
|
66
|
-
target: '.time-tracker-card',
|
|
67
|
-
view: 'overview',
|
|
68
|
-
pos: 'top',
|
|
69
|
-
},
|
|
70
|
-
|
|
71
|
-
// 6 — Topbar: búsqueda
|
|
62
|
+
// 5 — Topbar: búsqueda
|
|
72
63
|
{
|
|
73
64
|
titleKey: 'ui.onboarding.search.title',
|
|
74
65
|
descKey: 'ui.onboarding.search.desc',
|
|
75
66
|
target: '.topbar-search',
|
|
76
|
-
view: '
|
|
67
|
+
view: 'dashboard',
|
|
77
68
|
pos: 'bottom',
|
|
78
69
|
},
|
|
79
70
|
|
|
80
|
-
// 7 —
|
|
81
|
-
{
|
|
82
|
-
titleKey: 'ui.onboarding.board.title',
|
|
83
|
-
descKey: 'ui.onboarding.board.desc',
|
|
84
|
-
target: '.board-grid',
|
|
85
|
-
view: 'board',
|
|
86
|
-
pos: 'top',
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
// 8 — Editor de tareas
|
|
71
|
+
// 7 — Tasks (Kanban mode)
|
|
90
72
|
{
|
|
91
73
|
titleKey: 'ui.onboarding.tasks.title',
|
|
92
74
|
descKey: 'ui.onboarding.tasks.desc',
|
|
93
|
-
target: '
|
|
75
|
+
target: '#task-list',
|
|
94
76
|
view: 'tasks',
|
|
95
77
|
pos: 'right',
|
|
96
78
|
},
|
|
97
79
|
|
|
98
|
-
//
|
|
80
|
+
// 8 — Terminal
|
|
99
81
|
{
|
|
100
82
|
titleKey: 'ui.onboarding.execution.title',
|
|
101
83
|
descKey: 'ui.onboarding.execution.desc',
|
|
102
84
|
target: '.terminal-surface',
|
|
103
|
-
view: '
|
|
85
|
+
view: 'terminal',
|
|
104
86
|
pos: 'top',
|
|
105
87
|
},
|
|
106
88
|
|
|
107
|
-
//
|
|
89
|
+
// 9 — Timeline
|
|
108
90
|
{
|
|
109
|
-
titleKey: 'ui.onboarding.
|
|
110
|
-
descKey: 'ui.onboarding.
|
|
111
|
-
target: '.
|
|
112
|
-
view: '
|
|
113
|
-
pos: '
|
|
91
|
+
titleKey: 'ui.onboarding.timeline.title',
|
|
92
|
+
descKey: 'ui.onboarding.timeline.desc',
|
|
93
|
+
target: '.timeline-container',
|
|
94
|
+
view: 'timeline',
|
|
95
|
+
pos: 'top',
|
|
114
96
|
},
|
|
115
97
|
|
|
116
|
-
//
|
|
98
|
+
// 10 — Projects
|
|
117
99
|
{
|
|
118
|
-
titleKey: 'ui.onboarding.
|
|
119
|
-
descKey: 'ui.onboarding.
|
|
120
|
-
target: '
|
|
121
|
-
view: '
|
|
122
|
-
pos: '
|
|
100
|
+
titleKey: 'ui.onboarding.projects.title',
|
|
101
|
+
descKey: 'ui.onboarding.projects.desc',
|
|
102
|
+
target: '.projects-grid',
|
|
103
|
+
view: 'projects',
|
|
104
|
+
pos: 'top',
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// 11 — Keyboard shortcuts
|
|
108
|
+
{
|
|
109
|
+
titleKey: 'ui.onboarding.keyboard.title',
|
|
110
|
+
descKey: 'ui.onboarding.keyboard.desc',
|
|
111
|
+
target: null,
|
|
112
|
+
view: null,
|
|
113
|
+
pos: 'center',
|
|
123
114
|
},
|
|
124
115
|
|
|
125
116
|
// 12 — Theme toggle
|
|
@@ -127,7 +118,7 @@ const STEPS = [
|
|
|
127
118
|
titleKey: 'ui.onboarding.theme.title',
|
|
128
119
|
descKey: 'ui.onboarding.theme.desc',
|
|
129
120
|
target: '#theme-toggle-btn',
|
|
130
|
-
view: '
|
|
121
|
+
view: 'dashboard',
|
|
131
122
|
pos: 'bottom',
|
|
132
123
|
},
|
|
133
124
|
|
package/ui/js/router.js
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import * as state from './state.js';
|
|
8
8
|
|
|
9
|
+
const _redirects = {
|
|
10
|
+
overview: 'dashboard',
|
|
11
|
+
insights: 'dashboard',
|
|
12
|
+
board: 'tasks',
|
|
13
|
+
skills: 'settings',
|
|
14
|
+
execution: 'terminal',
|
|
15
|
+
};
|
|
16
|
+
|
|
9
17
|
/** @type {Map<string, function(): HTMLElement|string>} */
|
|
10
18
|
const _registry = new Map();
|
|
11
19
|
let _currentView = null;
|
|
@@ -108,7 +116,12 @@ export function current() { return _currentView; }
|
|
|
108
116
|
* Handler de hashchange
|
|
109
117
|
*/
|
|
110
118
|
function _handleHash() {
|
|
111
|
-
|
|
119
|
+
let hash = window.location.hash.slice(1);
|
|
120
|
+
// Redirect old routes
|
|
121
|
+
if (_redirects[hash]) {
|
|
122
|
+
hash = _redirects[hash];
|
|
123
|
+
history.replaceState(null, '', `#${hash}`);
|
|
124
|
+
}
|
|
112
125
|
if (hash && _registry.has(hash) && hash !== _currentView) {
|
|
113
126
|
navigate(hash, false);
|
|
114
127
|
}
|
|
@@ -118,8 +131,12 @@ function _handleHash() {
|
|
|
118
131
|
* Carga la vista inicial desde el hash o usa la vista por defecto
|
|
119
132
|
* @param {string} [defaultView='overview']
|
|
120
133
|
*/
|
|
121
|
-
export async function start(defaultView = '
|
|
122
|
-
|
|
134
|
+
export async function start(defaultView = 'dashboard') {
|
|
135
|
+
let hash = window.location.hash.slice(1);
|
|
136
|
+
if (_redirects[hash]) {
|
|
137
|
+
hash = _redirects[hash];
|
|
138
|
+
history.replaceState(null, '', `#${hash}`);
|
|
139
|
+
}
|
|
123
140
|
const initial = hash && _registry.has(hash) ? hash : defaultView;
|
|
124
141
|
await navigate(initial, true);
|
|
125
142
|
}
|
package/ui/js/views/board.js
CHANGED
|
@@ -1,97 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* board.js —
|
|
2
|
+
* board.js — Headless Kanban module (embeddable inside tasks.js)
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* renderKanban(tasks, phases, opts) — returns kanban board HTML (columns + cards only)
|
|
6
|
+
* bindKanbanEvents() — binds drag-drop, card click/selection, task actions
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import { icon } from '../icons.js';
|
|
6
10
|
import * as state from '../state.js';
|
|
7
11
|
import * as api from '../api.js';
|
|
8
|
-
import * as router from '../router.js';
|
|
9
12
|
import { flash } from './flash.js';
|
|
10
|
-
import { esc
|
|
13
|
+
import { esc } from '../utils.js';
|
|
11
14
|
import { t } from '../i18n.js';
|
|
12
15
|
|
|
13
16
|
const COLUMNS = [
|
|
14
17
|
{ id: 'pending', label: 'Pending' },
|
|
15
18
|
{ id: 'in_progress', label: 'In Progress' },
|
|
16
|
-
{ id: 'in_review',
|
|
17
|
-
{ id: 'blocked',
|
|
18
|
-
{ id: 'completed',
|
|
19
|
+
{ id: 'in_review', label: 'In Review' },
|
|
20
|
+
{ id: 'blocked', label: 'Blocked' },
|
|
21
|
+
{ id: 'completed', label: 'Completed' },
|
|
19
22
|
];
|
|
20
23
|
|
|
21
24
|
let _dragTaskId = null;
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Render the kanban board HTML (columns + cards only).
|
|
28
|
+
*
|
|
29
|
+
* @param {Array} tasks — pre-filtered task list
|
|
30
|
+
* @param {Array} phases — array of phase objects ({ id, label, ... })
|
|
31
|
+
* @param {Object} opts — { showCompleted, showCancelled, wipLimits }
|
|
32
|
+
* wipLimits is an optional map: { status: number }
|
|
33
|
+
* @returns {string} HTML string for the board grid
|
|
34
|
+
*/
|
|
35
|
+
export function renderKanban(tasks, phases, opts = {}) {
|
|
36
|
+
const { showCompleted = true, showCancelled = false, wipLimits = {} } = opts;
|
|
37
|
+
|
|
38
|
+
// Filter out completed / cancelled unless opted-in
|
|
39
|
+
let visibleTasks = tasks;
|
|
40
|
+
if (!showCompleted) {
|
|
41
|
+
visibleTasks = visibleTasks.filter(t => t.status !== 'completed');
|
|
42
|
+
}
|
|
43
|
+
if (!showCancelled) {
|
|
44
|
+
visibleTasks = visibleTasks.filter(t => t.status !== 'cancelled');
|
|
45
|
+
}
|
|
26
46
|
|
|
27
|
-
|
|
28
|
-
const hasCancelled =
|
|
47
|
+
// Build column set
|
|
48
|
+
const hasCancelled = showCancelled && visibleTasks.some(t => t.status === 'cancelled');
|
|
29
49
|
const columns = hasCancelled
|
|
30
50
|
? [...COLUMNS, { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') }]
|
|
31
51
|
: COLUMNS.map(col => ({ ...col, label: t(`status.${col.id}`, {}, col.label) }));
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
<div class="
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<div style="display:flex;gap:var(--space-2)">
|
|
41
|
-
<button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
|
|
42
|
-
${icon('check', 14)} ${_showCompleted() ? t('ui.board.hideCompleted', {}, 'Hide completed') : t('ui.board.showCompleted', {}, 'Show completed')}
|
|
43
|
-
</button>
|
|
44
|
-
<button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="${t('ui.board.newTask', {}, 'Create new task')}">
|
|
45
|
-
${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
|
|
46
|
-
</button>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
|
|
51
|
-
${columns.map(col => {
|
|
52
|
-
const colTasks = tasks.filter(t => t.status === col.id);
|
|
53
|
-
return _renderColumn(col, colTasks);
|
|
54
|
-
}).join('')}
|
|
55
|
-
</div>
|
|
53
|
+
return `
|
|
54
|
+
<div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
|
|
55
|
+
${columns.map(col => {
|
|
56
|
+
const colTasks = visibleTasks.filter(t => t.status === col.id);
|
|
57
|
+
const wipLimit = wipLimits[col.id] ?? null;
|
|
58
|
+
return _renderColumn(col, colTasks, phases, wipLimit);
|
|
59
|
+
}).join('')}
|
|
56
60
|
</div>
|
|
57
61
|
`;
|
|
58
|
-
|
|
59
|
-
setTimeout(() => _bindEvents(), 0);
|
|
60
|
-
return html;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Bind kanban-specific events: drag-drop, card click/selection, task actions.
|
|
66
|
+
* Call this after the board HTML has been inserted into the DOM.
|
|
67
|
+
*/
|
|
68
|
+
export function bindKanbanEvents() {
|
|
69
|
+
const board = document.getElementById('board');
|
|
70
|
+
if (!board) return;
|
|
71
|
+
|
|
72
|
+
// Click on task card — select and highlight
|
|
73
|
+
board.addEventListener('click', e => {
|
|
74
|
+
const card = e.target.closest('.task-card');
|
|
75
|
+
if (!card) return;
|
|
76
|
+
|
|
77
|
+
const id = card.dataset.taskId;
|
|
78
|
+
state.update('selectedTaskId', id);
|
|
79
|
+
|
|
80
|
+
// Highlight the selected card without a full re-render
|
|
81
|
+
board.querySelectorAll('.task-card').forEach(c => {
|
|
82
|
+
c.classList.toggle('is-selected', c.dataset.taskId === id);
|
|
83
|
+
c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Keyboard on cards (Enter = select)
|
|
88
|
+
board.addEventListener('keydown', e => {
|
|
89
|
+
const card = e.target.closest('.task-card');
|
|
90
|
+
if (!card) return;
|
|
91
|
+
if (e.key === 'Enter') {
|
|
92
|
+
state.update('selectedTaskId', card.dataset.taskId);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
78
95
|
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
// Drag & drop
|
|
97
|
+
_bindDragDrop(board);
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
/* ------------------------------------------------------------------ */
|
|
101
|
+
/* Internal helpers */
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
|
|
104
|
+
function _renderColumn(col, tasks, phases, wipLimit) {
|
|
105
|
+
const wipExceeded = wipLimit != null && tasks.length > wipLimit;
|
|
106
|
+
const wipAttr = wipLimit != null ? ` data-wip-limit="${wipLimit}"` : '';
|
|
107
|
+
|
|
84
108
|
return `
|
|
85
|
-
<section class="board-column col-${col.id}" data-status="${col.id}" aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
|
|
86
|
-
<div class="board-column-header">
|
|
109
|
+
<section class="board-column col-${col.id}" data-status="${col.id}"${wipAttr} aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
|
|
110
|
+
<div class="board-column-header${wipExceeded ? ' wip-exceeded' : ''}">
|
|
87
111
|
<h3 class="board-column-title" id="col-${col.id}">
|
|
88
112
|
<span class="board-column-dot" aria-hidden="true"></span>
|
|
89
113
|
${esc(col.label)}
|
|
90
114
|
</h3>
|
|
91
|
-
<span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}</span>
|
|
115
|
+
<span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}${wipLimit != null ? `/${wipLimit}` : ''}</span>
|
|
92
116
|
</div>
|
|
93
117
|
<div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
|
|
94
|
-
${tasks.map(
|
|
118
|
+
${tasks.map(task => _renderCard(task, phases)).join('')}
|
|
95
119
|
${tasks.length === 0
|
|
96
120
|
? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">${t('ui.board.noTasks', {}, 'No tasks')}</div>`
|
|
97
121
|
: ''}
|
|
@@ -100,10 +124,9 @@ function _renderColumn(col, tasks) {
|
|
|
100
124
|
`;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
|
-
function _renderCard(task) {
|
|
127
|
+
function _renderCard(task, phases) {
|
|
104
128
|
const isSelected = task.id === state.get('selectedTaskId');
|
|
105
129
|
const statusLabels = state.getStatusLabels();
|
|
106
|
-
const phases = state.getPhases();
|
|
107
130
|
const phaseInfo = phases.find(p => p.id === task.phase);
|
|
108
131
|
|
|
109
132
|
const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
@@ -132,59 +155,7 @@ function _renderCard(task) {
|
|
|
132
155
|
`;
|
|
133
156
|
}
|
|
134
157
|
|
|
135
|
-
function _bindEvents() {
|
|
136
|
-
const board = document.getElementById('board');
|
|
137
|
-
if (!board) return;
|
|
138
|
-
|
|
139
|
-
// Clic en task card → seleccionar y navegar a tasks
|
|
140
|
-
board.addEventListener('click', e => {
|
|
141
|
-
const card = e.target.closest('.task-card');
|
|
142
|
-
if (!card) return;
|
|
143
|
-
|
|
144
|
-
const id = card.dataset.taskId;
|
|
145
|
-
state.update('selectedTaskId', id);
|
|
146
|
-
|
|
147
|
-
// Navegar al editor si hubo doble clic; si un clic simple, solo marcar
|
|
148
|
-
if (e.detail === 2) {
|
|
149
|
-
router.navigate('tasks');
|
|
150
|
-
} else {
|
|
151
|
-
// Resaltar la card seleccionada sin re-render completo
|
|
152
|
-
board.querySelectorAll('.task-card').forEach(c => {
|
|
153
|
-
c.classList.toggle('is-selected', c.dataset.taskId === id);
|
|
154
|
-
c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Teclado en cards (Enter = seleccionar, Space = toggle)
|
|
160
|
-
board.addEventListener('keydown', e => {
|
|
161
|
-
const card = e.target.closest('.task-card');
|
|
162
|
-
if (!card) return;
|
|
163
|
-
if (e.key === 'Enter') {
|
|
164
|
-
state.update('selectedTaskId', card.dataset.taskId);
|
|
165
|
-
router.navigate('tasks');
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Toggle mostrar completadas
|
|
170
|
-
document.getElementById('board-filter-done')?.addEventListener('click', () => {
|
|
171
|
-
const current = _showCompleted();
|
|
172
|
-
sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
|
|
173
|
-
router.refresh();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Nueva tarea
|
|
177
|
-
document.getElementById('new-task-btn')?.addEventListener('click', () => {
|
|
178
|
-
state.update('selectedTaskId', null);
|
|
179
|
-
router.navigate('tasks');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// Drag & drop
|
|
183
|
-
_bindDragDrop(board);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
158
|
function _bindDragDrop(board) {
|
|
187
|
-
// Dragstart
|
|
188
159
|
board.addEventListener('dragstart', e => {
|
|
189
160
|
const card = e.target.closest('.task-card');
|
|
190
161
|
if (!card) return;
|
|
@@ -213,7 +184,6 @@ function _bindDragDrop(board) {
|
|
|
213
184
|
board.addEventListener('dragleave', e => {
|
|
214
185
|
const col = e.target.closest('.board-column');
|
|
215
186
|
if (!col) return;
|
|
216
|
-
// Solo eliminar si salimos de la columna, no de un hijo
|
|
217
187
|
if (!col.contains(e.relatedTarget)) {
|
|
218
188
|
col.classList.remove('is-drop-target');
|
|
219
189
|
}
|