trackfw 2.5.4 → 2.7.0
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 +88 -3
- package/package.json +1 -1
- package/src/commands/index.js +3 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/serve.js +184 -0
- package/src/serve/api_board.js +104 -0
- package/src/serve/api_chain.js +223 -0
- package/src/serve/api_file.js +80 -0
- package/src/serve/api_metrics.js +145 -0
- package/src/serve/static/app.js +687 -0
- package/src/serve/static/index.html +199 -0
- package/src/serve/static/style.css +153 -0
- package/src/validator/index.js +6 -5
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/* trackfw dashboard — app.js
|
|
2
|
+
Tecnologias: HTMX + marked.js + Chart.js + D3.js (todos via CDN)
|
|
3
|
+
Sem bundler, sem npm no runtime.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// ─── Estado global ────────────────────────────────────────────────────────────
|
|
9
|
+
let _currentView = 'board';
|
|
10
|
+
let _boardData = null; // cache do /api/board
|
|
11
|
+
let _chainData = null; // cache do /api/chain
|
|
12
|
+
let _donutChart = null; // instância Chart.js donut
|
|
13
|
+
let _burnChart = null; // instância Chart.js burndown
|
|
14
|
+
let _d3Sim = null; // simulação D3 force
|
|
15
|
+
let _drawerPath = null; // path atualmente aberto no drawer
|
|
16
|
+
|
|
17
|
+
// Cores canonicas por estado
|
|
18
|
+
const STATE_COLORS = {
|
|
19
|
+
wip: '#3b82f6',
|
|
20
|
+
backlog: '#6b7280',
|
|
21
|
+
blocked: '#ef4444',
|
|
22
|
+
done: '#22c55e',
|
|
23
|
+
abandoned: '#78716c',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Cores canonicas por tipo de nó
|
|
27
|
+
const NODE_COLORS = {
|
|
28
|
+
adr: '#3b82f6',
|
|
29
|
+
req: '#f97316',
|
|
30
|
+
roadmap: null, // usa STATE_COLORS[state]
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ─── Utilitários ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function truncate(str, max = 30) {
|
|
36
|
+
if (!str) return '';
|
|
37
|
+
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stateLabel(state) {
|
|
41
|
+
const labels = {
|
|
42
|
+
wip: 'WIP', backlog: 'BACKLOG', blocked: 'BLOCKED', done: 'DONE', abandoned: 'ABANDONED',
|
|
43
|
+
};
|
|
44
|
+
return labels[state] || state.toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function el(id) {
|
|
48
|
+
return document.getElementById(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function show(id) {
|
|
52
|
+
const e = el(id);
|
|
53
|
+
if (e) e.classList.remove('hidden');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hide(id) {
|
|
57
|
+
const e = el(id);
|
|
58
|
+
if (e) e.classList.add('hidden');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Navegação entre views ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function switchView(view) {
|
|
64
|
+
_currentView = view;
|
|
65
|
+
|
|
66
|
+
// Esconder todas as sections
|
|
67
|
+
document.querySelectorAll('.view-section').forEach(s => s.classList.add('hidden'));
|
|
68
|
+
|
|
69
|
+
// Atualizar tabs
|
|
70
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
71
|
+
btn.classList.remove('active');
|
|
72
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const activeTab = el('tab-' + view);
|
|
76
|
+
if (activeTab) {
|
|
77
|
+
activeTab.classList.add('active');
|
|
78
|
+
activeTab.setAttribute('aria-pressed', 'true');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const activeSection = el('view-' + view);
|
|
82
|
+
if (activeSection) activeSection.classList.remove('hidden');
|
|
83
|
+
|
|
84
|
+
// Carregar dados conforme view
|
|
85
|
+
if (view === 'board') {
|
|
86
|
+
loadBoard();
|
|
87
|
+
} else if (view === 'chain') {
|
|
88
|
+
loadChain();
|
|
89
|
+
} else if (view === 'metrics') {
|
|
90
|
+
loadMetrics();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── View: Board / Kanban ─────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const COLUMNS_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned'];
|
|
97
|
+
const COLUMNS_LABEL = {
|
|
98
|
+
wip: 'WIP', backlog: 'Backlog', blocked: 'Blocked', done: 'Done', abandoned: 'Abandoned',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function loadBoard() {
|
|
102
|
+
const container = el('board-container');
|
|
103
|
+
|
|
104
|
+
// Se já temos dados em cache, apenas re-renderizar
|
|
105
|
+
if (_boardData) {
|
|
106
|
+
renderBoard(_boardData, el('agent-filter') ? el('agent-filter').value : '');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Loading
|
|
111
|
+
container.innerHTML = `
|
|
112
|
+
<div id="board-loading" class="flex items-center gap-2 text-gray-500 text-sm">
|
|
113
|
+
<svg class="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
114
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
115
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
116
|
+
</svg>
|
|
117
|
+
Carregando board...
|
|
118
|
+
</div>`;
|
|
119
|
+
hide('board-error');
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch('/api/board');
|
|
123
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
124
|
+
_boardData = await res.json();
|
|
125
|
+
|
|
126
|
+
// Popular dropdown de agentes
|
|
127
|
+
const agents = _boardData.agents || [];
|
|
128
|
+
const filterContainer = el('agent-filter-container');
|
|
129
|
+
const agentSelect = el('agent-filter');
|
|
130
|
+
|
|
131
|
+
if (agents.length > 1 && filterContainer && agentSelect) {
|
|
132
|
+
filterContainer.classList.remove('hidden');
|
|
133
|
+
// Limpar opções extras (manter "Todos")
|
|
134
|
+
while (agentSelect.options.length > 1) agentSelect.remove(1);
|
|
135
|
+
agents.forEach(agent => {
|
|
136
|
+
const opt = document.createElement('option');
|
|
137
|
+
opt.value = agent;
|
|
138
|
+
opt.textContent = agent;
|
|
139
|
+
agentSelect.appendChild(opt);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
renderBoard(_boardData, '');
|
|
144
|
+
} catch (err) {
|
|
145
|
+
container.innerHTML = '';
|
|
146
|
+
const errEl = el('board-error');
|
|
147
|
+
if (errEl) {
|
|
148
|
+
errEl.textContent = `Erro ao carregar board: ${err.message}`;
|
|
149
|
+
errEl.classList.remove('hidden');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderBoard(data, agentFilter) {
|
|
155
|
+
const container = el('board-container');
|
|
156
|
+
if (!container) return;
|
|
157
|
+
|
|
158
|
+
container.innerHTML = '';
|
|
159
|
+
|
|
160
|
+
COLUMNS_ORDER.forEach(state => {
|
|
161
|
+
let cards = (data.columns[state] || []);
|
|
162
|
+
if (agentFilter) {
|
|
163
|
+
cards = cards.filter(c => c.agent === agentFilter);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const col = document.createElement('div');
|
|
167
|
+
col.className = 'kanban-column bg-white rounded-lg border border-gray-200 shadow-sm flex flex-col';
|
|
168
|
+
col.setAttribute('aria-label', `Coluna ${COLUMNS_LABEL[state]}`);
|
|
169
|
+
|
|
170
|
+
// Header da coluna
|
|
171
|
+
const header = document.createElement('div');
|
|
172
|
+
header.className = 'px-4 py-3 border-b border-gray-200 flex items-center justify-between';
|
|
173
|
+
header.innerHTML = `
|
|
174
|
+
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500">${COLUMNS_LABEL[state]}</span>
|
|
175
|
+
<span class="badge-${state} text-xs font-bold px-2 py-0.5 rounded-full">${cards.length}</span>`;
|
|
176
|
+
col.appendChild(header);
|
|
177
|
+
|
|
178
|
+
// Cards
|
|
179
|
+
const cardsContainer = document.createElement('div');
|
|
180
|
+
cardsContainer.className = 'p-3 flex flex-col gap-2 flex-1 overflow-y-auto';
|
|
181
|
+
cardsContainer.style.maxHeight = 'calc(100vh - 160px)';
|
|
182
|
+
|
|
183
|
+
if (cards.length === 0) {
|
|
184
|
+
const empty = document.createElement('p');
|
|
185
|
+
empty.className = 'text-xs text-gray-400 text-center py-4';
|
|
186
|
+
empty.textContent = 'Nenhum item';
|
|
187
|
+
cardsContainer.appendChild(empty);
|
|
188
|
+
} else {
|
|
189
|
+
cards.forEach(card => {
|
|
190
|
+
cardsContainer.appendChild(createCard(card));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
col.appendChild(cardsContainer);
|
|
195
|
+
container.appendChild(col);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createCard(card) {
|
|
200
|
+
const div = document.createElement('div');
|
|
201
|
+
div.className = 'kanban-card bg-gray-50 border border-gray-200 rounded-md p-3 hover:bg-white';
|
|
202
|
+
div.setAttribute('tabindex', '0');
|
|
203
|
+
div.setAttribute('role', 'button');
|
|
204
|
+
div.setAttribute('aria-label', `Abrir: ${card.title || card.file}`);
|
|
205
|
+
|
|
206
|
+
div.innerHTML = `
|
|
207
|
+
<p class="text-xs font-semibold text-gray-800 mb-2 leading-snug">${escapeHtml(card.title || card.file)}</p>
|
|
208
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
209
|
+
${card.agent ? `<span class="inline-block text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">${escapeHtml(card.agent)}</span>` : ''}
|
|
210
|
+
<span class="badge-${card.state} inline-block text-xs px-1.5 py-0.5 rounded font-medium">${stateLabel(card.state)}</span>
|
|
211
|
+
</div>`;
|
|
212
|
+
|
|
213
|
+
div.addEventListener('click', () => openDrawer(card.path));
|
|
214
|
+
div.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDrawer(card.path); } });
|
|
215
|
+
|
|
216
|
+
return div;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function filterByAgent(agent) {
|
|
220
|
+
if (_boardData) {
|
|
221
|
+
renderBoard(_boardData, agent);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── View: Chain (D3 force-directed graph) ────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
async function loadChain() {
|
|
228
|
+
hide('chain-error');
|
|
229
|
+
|
|
230
|
+
// Se já tem dados, re-renderizar
|
|
231
|
+
if (_chainData) {
|
|
232
|
+
renderChain(_chainData);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
show('chain-loading');
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch('/api/chain');
|
|
240
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
241
|
+
_chainData = await res.json();
|
|
242
|
+
hide('chain-loading');
|
|
243
|
+
renderChain(_chainData);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
hide('chain-loading');
|
|
246
|
+
const errEl = el('chain-error');
|
|
247
|
+
if (errEl) {
|
|
248
|
+
errEl.textContent = `Erro ao carregar cadeia: ${err.message}`;
|
|
249
|
+
errEl.classList.remove('hidden');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderChain(data) {
|
|
255
|
+
const svgEl = el('chain-svg');
|
|
256
|
+
if (!svgEl) return;
|
|
257
|
+
|
|
258
|
+
// Limpar SVG anterior
|
|
259
|
+
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
|
|
260
|
+
|
|
261
|
+
const width = svgEl.clientWidth || window.innerWidth;
|
|
262
|
+
const height = svgEl.clientHeight || window.innerHeight - 60;
|
|
263
|
+
|
|
264
|
+
svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
265
|
+
|
|
266
|
+
const svg = d3.select(svgEl);
|
|
267
|
+
|
|
268
|
+
// Defs para a seta
|
|
269
|
+
svg.append('defs').append('marker')
|
|
270
|
+
.attr('id', 'arrowhead')
|
|
271
|
+
.attr('viewBox', '-0 -5 10 10')
|
|
272
|
+
.attr('refX', 22)
|
|
273
|
+
.attr('refY', 0)
|
|
274
|
+
.attr('orient', 'auto')
|
|
275
|
+
.attr('markerWidth', 6)
|
|
276
|
+
.attr('markerHeight', 6)
|
|
277
|
+
.append('path')
|
|
278
|
+
.attr('d', 'M 0,-5 L 10,0 L 0,5')
|
|
279
|
+
.attr('fill', '#9ca3af');
|
|
280
|
+
|
|
281
|
+
// Grupo raiz (para zoom/pan)
|
|
282
|
+
const g = svg.append('g');
|
|
283
|
+
|
|
284
|
+
// Zoom
|
|
285
|
+
const zoom = d3.zoom()
|
|
286
|
+
.scaleExtent([0.2, 4])
|
|
287
|
+
.on('zoom', (event) => g.attr('transform', event.transform));
|
|
288
|
+
svg.call(zoom);
|
|
289
|
+
|
|
290
|
+
// Preparar nodes e edges (deep copy para D3 mutate)
|
|
291
|
+
const nodes = (data.nodes || []).map(n => ({ ...n }));
|
|
292
|
+
const edges = (data.edges || []).map(e => ({ ...e }));
|
|
293
|
+
|
|
294
|
+
// Links com referência aos objetos de node
|
|
295
|
+
const nodeById = {};
|
|
296
|
+
nodes.forEach(n => { nodeById[n.id] = n; });
|
|
297
|
+
|
|
298
|
+
const links = edges.map(e => ({
|
|
299
|
+
source: nodeById[e.from] || e.from,
|
|
300
|
+
target: nodeById[e.to] || e.to,
|
|
301
|
+
})).filter(l => l.source && l.target);
|
|
302
|
+
|
|
303
|
+
// Simulação
|
|
304
|
+
if (_d3Sim) _d3Sim.stop();
|
|
305
|
+
|
|
306
|
+
_d3Sim = d3.forceSimulation(nodes)
|
|
307
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(120).strength(0.6))
|
|
308
|
+
.force('charge', d3.forceManyBody().strength(-280))
|
|
309
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
310
|
+
.force('collision', d3.forceCollide(35));
|
|
311
|
+
|
|
312
|
+
// Arestas
|
|
313
|
+
const link = g.append('g')
|
|
314
|
+
.attr('class', 'links')
|
|
315
|
+
.selectAll('line')
|
|
316
|
+
.data(links)
|
|
317
|
+
.enter().append('line')
|
|
318
|
+
.attr('class', 'link-line')
|
|
319
|
+
.attr('marker-end', 'url(#arrowhead)');
|
|
320
|
+
|
|
321
|
+
// Grupos de nós
|
|
322
|
+
const node = g.append('g')
|
|
323
|
+
.attr('class', 'nodes')
|
|
324
|
+
.selectAll('g')
|
|
325
|
+
.data(nodes)
|
|
326
|
+
.enter().append('g')
|
|
327
|
+
.attr('class', 'node-group')
|
|
328
|
+
.attr('cursor', 'pointer')
|
|
329
|
+
.on('click', (_, d) => openDrawer(d.id))
|
|
330
|
+
.call(
|
|
331
|
+
d3.drag()
|
|
332
|
+
.on('start', (event, d) => {
|
|
333
|
+
if (!event.active) _d3Sim.alphaTarget(0.3).restart();
|
|
334
|
+
d.fx = d.x; d.fy = d.y;
|
|
335
|
+
})
|
|
336
|
+
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
|
337
|
+
.on('end', (event, d) => {
|
|
338
|
+
if (!event.active) _d3Sim.alphaTarget(0);
|
|
339
|
+
d.fx = null; d.fy = null;
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Círculo de cada nó
|
|
344
|
+
node.append('circle')
|
|
345
|
+
.attr('r', 18)
|
|
346
|
+
.attr('fill', d => {
|
|
347
|
+
if (d.type === 'adr') return NODE_COLORS.adr;
|
|
348
|
+
if (d.type === 'req') return NODE_COLORS.req;
|
|
349
|
+
return STATE_COLORS[d.state] || '#6b7280';
|
|
350
|
+
})
|
|
351
|
+
.attr('stroke', '#fff')
|
|
352
|
+
.attr('stroke-width', 2);
|
|
353
|
+
|
|
354
|
+
// Ícone/sigla dentro do círculo
|
|
355
|
+
node.append('text')
|
|
356
|
+
.attr('text-anchor', 'middle')
|
|
357
|
+
.attr('dy', '0.35em')
|
|
358
|
+
.attr('fill', '#fff')
|
|
359
|
+
.attr('font-size', '9px')
|
|
360
|
+
.attr('font-weight', '700')
|
|
361
|
+
.attr('pointer-events', 'none')
|
|
362
|
+
.text(d => {
|
|
363
|
+
if (d.type === 'adr') return 'ADR';
|
|
364
|
+
if (d.type === 'req') return 'REQ';
|
|
365
|
+
return 'RM';
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Label abaixo do círculo
|
|
369
|
+
node.append('text')
|
|
370
|
+
.attr('class', 'node-label')
|
|
371
|
+
.attr('text-anchor', 'middle')
|
|
372
|
+
.attr('dy', '2.4em')
|
|
373
|
+
.text(d => truncate(d.title || d.id, 28));
|
|
374
|
+
|
|
375
|
+
// Tooltip básico via title SVG
|
|
376
|
+
node.append('title').text(d => `${d.type.toUpperCase()}: ${d.title || d.id}\nEstado: ${d.state || '—'}`);
|
|
377
|
+
|
|
378
|
+
// Tick
|
|
379
|
+
_d3Sim.on('tick', () => {
|
|
380
|
+
link
|
|
381
|
+
.attr('x1', d => d.source.x)
|
|
382
|
+
.attr('y1', d => d.source.y)
|
|
383
|
+
.attr('x2', d => d.target.x)
|
|
384
|
+
.attr('y2', d => d.target.y);
|
|
385
|
+
|
|
386
|
+
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── View: Metrics ────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
async function loadMetrics() {
|
|
393
|
+
hide('metrics-error');
|
|
394
|
+
hide('kpi-cards');
|
|
395
|
+
hide('charts-container');
|
|
396
|
+
show('metrics-loading');
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch('/api/metrics');
|
|
400
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
401
|
+
const data = await res.json();
|
|
402
|
+
hide('metrics-loading');
|
|
403
|
+
renderMetrics(data);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
hide('metrics-loading');
|
|
406
|
+
const errEl = el('metrics-error');
|
|
407
|
+
if (errEl) {
|
|
408
|
+
errEl.textContent = `Erro ao carregar métricas: ${err.message}`;
|
|
409
|
+
errEl.classList.remove('hidden');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function renderMetrics(data) {
|
|
415
|
+
// KPI cards
|
|
416
|
+
const leadEl = el('kpi-lead-time');
|
|
417
|
+
const cycleEl = el('kpi-cycle-time');
|
|
418
|
+
const abandonEl = el('kpi-abandonment');
|
|
419
|
+
|
|
420
|
+
if (leadEl) leadEl.textContent = `${(data.lead_time_avg_days || 0).toFixed(1)} dias`;
|
|
421
|
+
if (cycleEl) cycleEl.textContent = `${(data.cycle_time_avg_days || 0).toFixed(1)} dias`;
|
|
422
|
+
if (abandonEl) abandonEl.textContent = `${(((data.abandonment_rate || 0) * 100)).toFixed(1)}%`;
|
|
423
|
+
|
|
424
|
+
show('kpi-cards');
|
|
425
|
+
|
|
426
|
+
// Donut chart — distribuição por estado
|
|
427
|
+
const dist = data.state_distribution || {};
|
|
428
|
+
const donutLabels = Object.keys(dist);
|
|
429
|
+
const donutValues = Object.values(dist);
|
|
430
|
+
const donutColors = donutLabels.map(s => STATE_COLORS[s] || '#9ca3af');
|
|
431
|
+
|
|
432
|
+
const donutCanvas = el('donut-chart');
|
|
433
|
+
if (donutCanvas) {
|
|
434
|
+
if (_donutChart) { _donutChart.destroy(); _donutChart = null; }
|
|
435
|
+
_donutChart = new Chart(donutCanvas, {
|
|
436
|
+
type: 'doughnut',
|
|
437
|
+
data: {
|
|
438
|
+
labels: donutLabels.map(stateLabel),
|
|
439
|
+
datasets: [{
|
|
440
|
+
data: donutValues,
|
|
441
|
+
backgroundColor: donutColors,
|
|
442
|
+
borderWidth: 2,
|
|
443
|
+
borderColor: '#fff',
|
|
444
|
+
}],
|
|
445
|
+
},
|
|
446
|
+
options: {
|
|
447
|
+
responsive: true,
|
|
448
|
+
maintainAspectRatio: true,
|
|
449
|
+
plugins: {
|
|
450
|
+
legend: {
|
|
451
|
+
position: 'bottom',
|
|
452
|
+
labels: { font: { size: 11 }, padding: 12 },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Burndown chart
|
|
460
|
+
const burndown = data.burndown || [];
|
|
461
|
+
const burndownCanvas = el('burndown-chart');
|
|
462
|
+
const burndownEmpty = el('burndown-empty');
|
|
463
|
+
|
|
464
|
+
if (burndown.length === 0) {
|
|
465
|
+
if (burndownCanvas) burndownCanvas.style.display = 'none';
|
|
466
|
+
if (burndownEmpty) burndownEmpty.classList.remove('hidden');
|
|
467
|
+
} else {
|
|
468
|
+
if (burndownEmpty) burndownEmpty.classList.add('hidden');
|
|
469
|
+
if (burndownCanvas) {
|
|
470
|
+
burndownCanvas.style.display = '';
|
|
471
|
+
if (_burnChart) { _burnChart.destroy(); _burnChart = null; }
|
|
472
|
+
_burnChart = new Chart(burndownCanvas, {
|
|
473
|
+
type: 'line',
|
|
474
|
+
data: {
|
|
475
|
+
labels: burndown.map(b => b.date),
|
|
476
|
+
datasets: [
|
|
477
|
+
{
|
|
478
|
+
label: 'Abertos',
|
|
479
|
+
data: burndown.map(b => b.open),
|
|
480
|
+
borderColor: '#ef4444',
|
|
481
|
+
backgroundColor: 'rgba(239,68,68,0.08)',
|
|
482
|
+
pointRadius: 4,
|
|
483
|
+
tension: 0.3,
|
|
484
|
+
fill: true,
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
label: 'Fechados',
|
|
488
|
+
data: burndown.map(b => b.closed),
|
|
489
|
+
borderColor: '#22c55e',
|
|
490
|
+
backgroundColor: 'rgba(34,197,94,0.08)',
|
|
491
|
+
pointRadius: 4,
|
|
492
|
+
tension: 0.3,
|
|
493
|
+
fill: true,
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
options: {
|
|
498
|
+
responsive: true,
|
|
499
|
+
maintainAspectRatio: false,
|
|
500
|
+
scales: {
|
|
501
|
+
x: { ticks: { font: { size: 10 } } },
|
|
502
|
+
y: { beginAtZero: true, ticks: { font: { size: 10 }, precision: 0 } },
|
|
503
|
+
},
|
|
504
|
+
plugins: {
|
|
505
|
+
legend: { labels: { font: { size: 11 } } },
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
show('charts-container');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Drawer lateral ───────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
async function openDrawer(path) {
|
|
518
|
+
if (!path) return;
|
|
519
|
+
_drawerPath = path;
|
|
520
|
+
|
|
521
|
+
const drawer = el('drawer');
|
|
522
|
+
const overlay = el('drawer-overlay');
|
|
523
|
+
|
|
524
|
+
// Exibir drawer e overlay
|
|
525
|
+
drawer.style.display = 'flex';
|
|
526
|
+
overlay.classList.remove('hidden');
|
|
527
|
+
drawer.classList.remove('hidden');
|
|
528
|
+
|
|
529
|
+
// Forçar re-trigger da animação
|
|
530
|
+
drawer.classList.remove('drawer-slide');
|
|
531
|
+
void drawer.offsetWidth;
|
|
532
|
+
drawer.classList.add('drawer-slide');
|
|
533
|
+
|
|
534
|
+
// Filename no header
|
|
535
|
+
const filenameEl = el('drawer-filename');
|
|
536
|
+
if (filenameEl) filenameEl.textContent = path.split('/').pop();
|
|
537
|
+
|
|
538
|
+
// Resetar conteúdo
|
|
539
|
+
hide('drawer-content');
|
|
540
|
+
hide('drawer-error');
|
|
541
|
+
show('drawer-loading');
|
|
542
|
+
|
|
543
|
+
const mdEl = el('drawer-markdown');
|
|
544
|
+
const fmEl = el('drawer-frontmatter');
|
|
545
|
+
if (mdEl) mdEl.innerHTML = '';
|
|
546
|
+
if (fmEl) fmEl.classList.add('hidden');
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
|
550
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
551
|
+
const raw = await res.text();
|
|
552
|
+
|
|
553
|
+
hide('drawer-loading');
|
|
554
|
+
|
|
555
|
+
// Extrair e renderizar frontmatter
|
|
556
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
557
|
+
if (frontmatter && Object.keys(frontmatter).length > 0) {
|
|
558
|
+
renderFrontmatterTable(frontmatter);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Renderizar markdown
|
|
562
|
+
if (mdEl) {
|
|
563
|
+
mdEl.innerHTML = marked.parse(body || raw);
|
|
564
|
+
|
|
565
|
+
// Interceptar links internos .md
|
|
566
|
+
mdEl.querySelectorAll('a[href]').forEach(a => {
|
|
567
|
+
const href = a.getAttribute('href');
|
|
568
|
+
if (!href) return;
|
|
569
|
+
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
|
570
|
+
const isMdLink = href.endsWith('.md');
|
|
571
|
+
if (!isExternal && isMdLink) {
|
|
572
|
+
a.addEventListener('click', e => {
|
|
573
|
+
e.preventDefault();
|
|
574
|
+
openDrawer(href);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
show('drawer-content');
|
|
581
|
+
} catch (err) {
|
|
582
|
+
hide('drawer-loading');
|
|
583
|
+
const errEl = el('drawer-error');
|
|
584
|
+
if (errEl) {
|
|
585
|
+
errEl.textContent = `Erro ao carregar arquivo: ${err.message}`;
|
|
586
|
+
errEl.classList.remove('hidden');
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function closeDrawer() {
|
|
592
|
+
const drawer = el('drawer');
|
|
593
|
+
const overlay = el('drawer-overlay');
|
|
594
|
+
if (drawer) drawer.style.display = 'none';
|
|
595
|
+
if (overlay) overlay.classList.add('hidden');
|
|
596
|
+
_drawerPath = null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ─── Frontmatter YAML ─────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Extrai o bloco YAML entre --- e --- do início do markdown.
|
|
603
|
+
* Retorna { frontmatter: Object, body: string }.
|
|
604
|
+
*/
|
|
605
|
+
function parseFrontmatter(md) {
|
|
606
|
+
if (!md || !md.trimStart().startsWith('---')) {
|
|
607
|
+
return { frontmatter: null, body: md };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const lines = md.split('\n');
|
|
611
|
+
let endIndex = -1;
|
|
612
|
+
|
|
613
|
+
// Pular a primeira linha (---) e procurar o fechamento
|
|
614
|
+
for (let i = 1; i < lines.length; i++) {
|
|
615
|
+
if (lines[i].trim() === '---') {
|
|
616
|
+
endIndex = i;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (endIndex === -1) {
|
|
622
|
+
return { frontmatter: null, body: md };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const yamlLines = lines.slice(1, endIndex);
|
|
626
|
+
const body = lines.slice(endIndex + 1).join('\n');
|
|
627
|
+
const frontmatter = {};
|
|
628
|
+
|
|
629
|
+
yamlLines.forEach(line => {
|
|
630
|
+
const colonIdx = line.indexOf(':');
|
|
631
|
+
if (colonIdx === -1) return;
|
|
632
|
+
const key = line.slice(0, colonIdx).trim();
|
|
633
|
+
const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
634
|
+
if (key) frontmatter[key] = value;
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
return { frontmatter, body };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Renderiza o objeto frontmatter como tabela no drawer.
|
|
642
|
+
*/
|
|
643
|
+
function renderFrontmatterTable(frontmatter) {
|
|
644
|
+
const container = el('drawer-frontmatter');
|
|
645
|
+
const tbody = document.querySelector('#frontmatter-table tbody');
|
|
646
|
+
if (!container || !tbody) return;
|
|
647
|
+
|
|
648
|
+
tbody.innerHTML = '';
|
|
649
|
+
|
|
650
|
+
Object.entries(frontmatter).forEach(([key, value]) => {
|
|
651
|
+
const tr = document.createElement('tr');
|
|
652
|
+
const tdKey = document.createElement('td');
|
|
653
|
+
const tdVal = document.createElement('td');
|
|
654
|
+
tdKey.textContent = key;
|
|
655
|
+
tdVal.textContent = value;
|
|
656
|
+
tr.appendChild(tdKey);
|
|
657
|
+
tr.appendChild(tdVal);
|
|
658
|
+
tbody.appendChild(tr);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
container.classList.remove('hidden');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ─── Escape HTML ──────────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
function escapeHtml(str) {
|
|
667
|
+
if (!str) return '';
|
|
668
|
+
return str
|
|
669
|
+
.replace(/&/g, '&')
|
|
670
|
+
.replace(/</g, '<')
|
|
671
|
+
.replace(/>/g, '>')
|
|
672
|
+
.replace(/"/g, '"')
|
|
673
|
+
.replace(/'/g, ''');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ─── Atalho de teclado: Escape fecha o drawer ─────────────────────────────────
|
|
677
|
+
|
|
678
|
+
document.addEventListener('keydown', e => {
|
|
679
|
+
if (e.key === 'Escape') closeDrawer();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// ─── Init ─────────────────────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
685
|
+
// Ativar tab Board e carregar dados iniciais
|
|
686
|
+
switchView('board');
|
|
687
|
+
});
|