@cccarv82/freya 2.15.0 → 2.17.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/cli/web-ui.css +154 -1
- package/cli/web-ui.js +194 -306
- package/cli/web.js +445 -550
- package/package.json +2 -3
- package/scripts/build-vector-index.js +87 -35
- package/templates/base/scripts/build-vector-index.js +87 -35
- package/scripts/generate-weekly-report.js +0 -128
package/cli/web.js
CHANGED
|
@@ -273,48 +273,6 @@ function splitForDiscord(text, limit = 1900) {
|
|
|
273
273
|
return parts;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
function parseIncidentMarkdown(md) {
|
|
277
|
-
const lines = String(md || '').split(/\r?\n/);
|
|
278
|
-
const items = [];
|
|
279
|
-
let current = null;
|
|
280
|
-
for (const line of lines) {
|
|
281
|
-
if (line.startsWith('- **')) {
|
|
282
|
-
if (current) items.push(current);
|
|
283
|
-
current = { title: line.replace('- **', '').replace('**', '').trim(), body: [] };
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
if (current && line.trim().startsWith('- ')) {
|
|
287
|
-
current.body.push(line.trim().replace(/^- /, ''));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (current) items.push(current);
|
|
291
|
-
return items;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function serializeIncidentMarkdown(items) {
|
|
295
|
-
return (items || []).map((it) => {
|
|
296
|
-
const body = Array.isArray(it.body) ? it.body : [];
|
|
297
|
-
const lines = ['- **' + (it.title || 'Incidente') + '**'];
|
|
298
|
-
for (const b of body) lines.push('- ' + b);
|
|
299
|
-
return lines.join('\n');
|
|
300
|
-
}).join('\n');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function resolveIncidentInMarkdown(md, title, index) {
|
|
304
|
-
const items = parseIncidentMarkdown(md);
|
|
305
|
-
const matches = items.map((it, i) => ({ it, i })).filter((x) => x.it.title === title);
|
|
306
|
-
if (!matches.length) return null;
|
|
307
|
-
const pick = typeof index === 'number' && Number.isInteger(index) ? matches[index] : matches[0];
|
|
308
|
-
if (!pick) return null;
|
|
309
|
-
const entry = pick.it;
|
|
310
|
-
const body = Array.isArray(entry.body) ? entry.body : [];
|
|
311
|
-
const statusIdx = body.findIndex((b) => /^status\s*:/i.test(b));
|
|
312
|
-
if (statusIdx >= 0) body[statusIdx] = 'Status: resolved';
|
|
313
|
-
else body.push('Status: resolved');
|
|
314
|
-
entry.body = body;
|
|
315
|
-
return serializeIncidentMarkdown(items);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
276
|
function normalizePriority(value) {
|
|
319
277
|
const raw = String(value || '').trim().toLowerCase();
|
|
320
278
|
if (!raw) return '';
|
|
@@ -1272,55 +1230,78 @@ function buildHtml(safeDefault, appVersion) {
|
|
|
1272
1230
|
</div>
|
|
1273
1231
|
</div>
|
|
1274
1232
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
<
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
<
|
|
1233
|
+
<!-- Kanban toolbar -->
|
|
1234
|
+
<div class="kanban-toolbar" style="display:flex; justify-content:space-between; align-items:center; padding:0 0 16px; gap:12px; flex-wrap:wrap;">
|
|
1235
|
+
<div style="display:flex; gap:8px; align-items:center;">
|
|
1236
|
+
<select id="kanbanFilterProject" class="kanban-filter" onchange="window.filterKanban()">
|
|
1237
|
+
<option value="">Todos os projetos</option>
|
|
1238
|
+
</select>
|
|
1239
|
+
<button class="btn small" type="button" onclick="window.loadKanban()">
|
|
1240
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px;margin-right:4px"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
|
1241
|
+
Atualizar
|
|
1242
|
+
</button>
|
|
1243
|
+
<div class="statusLine" style="margin:0;">
|
|
1244
|
+
<span class="small" id="last"></span>
|
|
1245
|
+
</div>
|
|
1282
1246
|
</div>
|
|
1247
|
+
<button class="btn primary small" type="button" onclick="window.openQuickAdd()">+ Nova Task</button>
|
|
1283
1248
|
</div>
|
|
1284
1249
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
<div class="panelHead" style="background: linear-gradient(90deg, var(--paper2), var(--paper)); border-left: 4px solid var(--accent); flex-shrink:0;">
|
|
1288
|
-
<div style="display:flex; align-items:center; gap:10px;">
|
|
1289
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent); flex-shrink:0;"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
|
1290
|
-
<b style="color: var(--text); font-size: 14px;">Foco de Hoje</b>
|
|
1291
|
-
<div id="focusSummaryChips" style="display:flex; gap:5px; margin-left:4px;"></div>
|
|
1292
|
-
</div>
|
|
1293
|
-
<div class="stack">
|
|
1294
|
-
<div id="focusProgressWrap" style="display:none; align-items:center; gap:6px; font-size:11px; color:var(--muted);">
|
|
1295
|
-
<div style="width:80px; height:5px; background:var(--border); border-radius:3px; overflow:hidden;">
|
|
1296
|
-
<div id="focusProgressBar" style="height:100%; background:var(--accent); border-radius:3px; transition:width 0.4s; width:0%"></div>
|
|
1297
|
-
</div>
|
|
1298
|
-
<span id="focusProgressLabel"></span>
|
|
1299
|
-
</div>
|
|
1300
|
-
<button class="btn small" type="button" onclick="refreshToday()">
|
|
1301
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px;margin-right:4px"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
|
1302
|
-
Atualizar
|
|
1303
|
-
</button>
|
|
1304
|
-
</div>
|
|
1305
|
-
</div>
|
|
1250
|
+
<!-- Delta banner -->
|
|
1251
|
+
<div id="kanbanDelta" style="display:none;"></div>
|
|
1306
1252
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1253
|
+
<!-- Kanban columns -->
|
|
1254
|
+
<div id="kanbanBoard" class="kanban-board">
|
|
1255
|
+
<div class="kanban-col" data-category="DO_NOW">
|
|
1256
|
+
<div class="kanban-col-head do-now">
|
|
1257
|
+
<span class="kanban-col-title">DO NOW</span>
|
|
1258
|
+
<span class="kanban-col-count" id="countDoNow">0</span>
|
|
1259
|
+
</div>
|
|
1260
|
+
<div class="kanban-col-body" id="colDoNow"></div>
|
|
1261
|
+
</div>
|
|
1262
|
+
<div class="kanban-col" data-category="SCHEDULE">
|
|
1263
|
+
<div class="kanban-col-head schedule">
|
|
1264
|
+
<span class="kanban-col-title">SCHEDULE</span>
|
|
1265
|
+
<span class="kanban-col-count" id="countSchedule">0</span>
|
|
1266
|
+
</div>
|
|
1267
|
+
<div class="kanban-col-body" id="colSchedule"></div>
|
|
1268
|
+
</div>
|
|
1269
|
+
<div class="kanban-col" data-category="DELEGATE">
|
|
1270
|
+
<div class="kanban-col-head delegate">
|
|
1271
|
+
<span class="kanban-col-title">DELEGATE</span>
|
|
1272
|
+
<span class="kanban-col-count" id="countDelegate">0</span>
|
|
1273
|
+
</div>
|
|
1274
|
+
<div class="kanban-col-body" id="colDelegate"></div>
|
|
1275
|
+
</div>
|
|
1276
|
+
<div class="kanban-col" data-category="COMPLETED">
|
|
1277
|
+
<div class="kanban-col-head done">
|
|
1278
|
+
<span class="kanban-col-title">DONE (7d)</span>
|
|
1279
|
+
<span class="kanban-col-count" id="countDone">0</span>
|
|
1310
1280
|
</div>
|
|
1281
|
+
<div class="kanban-col-body" id="colDone"></div>
|
|
1282
|
+
</div>
|
|
1283
|
+
</div>
|
|
1311
1284
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1285
|
+
<!-- Blockers strip below kanban -->
|
|
1286
|
+
<div id="kanbanBlockers" style="margin-top:20px; display:none;">
|
|
1287
|
+
<div style="font-size:13px; font-weight:700; color:var(--text); margin-bottom:8px; display:flex; align-items:center; gap:6px;">
|
|
1288
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
1289
|
+
Blockers Ativos
|
|
1290
|
+
</div>
|
|
1291
|
+
<div id="kanbanBlockersList" class="kanban-blockers-list"></div>
|
|
1292
|
+
</div>
|
|
1293
|
+
|
|
1294
|
+
<!-- Insights de bloqueios -->
|
|
1295
|
+
<div id="blockersInsightsWrap" style="margin-top:16px; border-top: 1px solid var(--border); display:none;">
|
|
1296
|
+
<div style="display:flex; justify-content:space-between; align-items:center; padding: 8px 0 4px;">
|
|
1297
|
+
<div style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; color:var(--muted);">
|
|
1298
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-1px;margin-right:4px"><path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
|
1299
|
+
Insights de Bloqueios
|
|
1322
1300
|
</div>
|
|
1323
|
-
|
|
1301
|
+
<button class="btn small" type="button" onclick="refreshBlockersInsights()" style="font-size:10px; padding:2px 6px;">↻</button>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div id="blockersInsights" style="padding: 4px 0 12px; font-size:12px; color:var(--muted);"></div>
|
|
1304
|
+
</div>
|
|
1324
1305
|
|
|
1325
1306
|
</div>
|
|
1326
1307
|
</main>
|
|
@@ -1352,7 +1333,11 @@ function buildHtml(safeDefault, appVersion) {
|
|
|
1352
1333
|
</div>
|
|
1353
1334
|
<div class="qa-row">
|
|
1354
1335
|
<input id="qaSlug" class="qa-input" placeholder="Projeto (slug)" style="flex:1;" />
|
|
1355
|
-
<
|
|
1336
|
+
<div class="qa-date-wrap">
|
|
1337
|
+
<input id="qaDue" type="text" class="qa-input qa-date-text" placeholder="dd/mm/aaaa" maxlength="10" />
|
|
1338
|
+
<input id="qaDuePicker" type="date" class="qa-date-hidden" tabindex="-1" />
|
|
1339
|
+
<button type="button" class="qa-date-btn" onclick="var p=this.parentElement.querySelector('#qaDuePicker'); p.showPicker ? p.showPicker() : p.click();" title="Abrir calendario">📅</button>
|
|
1340
|
+
</div>
|
|
1356
1341
|
</div>
|
|
1357
1342
|
<div class="qa-row" style="justify-content:flex-end;">
|
|
1358
1343
|
<button class="btn small" type="button" onclick="window.closeQuickAdd()">Cancelar</button>
|
|
@@ -2074,6 +2059,8 @@ function truncateText(text, maxLen) {
|
|
|
2074
2059
|
|
|
2075
2060
|
function getTimelineItems(workspaceDir) {
|
|
2076
2061
|
const items = [];
|
|
2062
|
+
|
|
2063
|
+
// Daily logs from filesystem (these are the raw markdown files)
|
|
2077
2064
|
const dailyDir = path.join(workspaceDir, 'logs', 'daily');
|
|
2078
2065
|
if (exists(dailyDir)) {
|
|
2079
2066
|
const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
@@ -2084,55 +2071,38 @@ function getTimelineItems(workspaceDir) {
|
|
|
2084
2071
|
items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
|
|
2085
2072
|
}
|
|
2086
2073
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
tags: h.tags || [],
|
|
2107
|
-
slug
|
|
2108
|
-
});
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2074
|
+
|
|
2075
|
+
// Project status history from SQLite
|
|
2076
|
+
if (dl.db) {
|
|
2077
|
+
try {
|
|
2078
|
+
const rows = dl.db.prepare(`
|
|
2079
|
+
SELECT p.slug, p.name, psh.status_text, psh.date
|
|
2080
|
+
FROM project_status_history psh
|
|
2081
|
+
JOIN projects p ON p.id = psh.project_id
|
|
2082
|
+
ORDER BY psh.date DESC
|
|
2083
|
+
`).all();
|
|
2084
|
+
for (const r of rows) {
|
|
2085
|
+
items.push({
|
|
2086
|
+
kind: 'status',
|
|
2087
|
+
date: String(r.date || '').slice(0, 10),
|
|
2088
|
+
title: `${r.name || r.slug} (Status)`,
|
|
2089
|
+
content: r.status_text || '',
|
|
2090
|
+
tags: [],
|
|
2091
|
+
slug: r.slug
|
|
2092
|
+
});
|
|
2111
2093
|
}
|
|
2112
|
-
}
|
|
2094
|
+
} catch { /* db not ready */ }
|
|
2113
2095
|
}
|
|
2114
2096
|
|
|
2115
|
-
//
|
|
2116
|
-
const seenIds = new Set();
|
|
2097
|
+
// Tasks from SQLite
|
|
2117
2098
|
if (dl.db) {
|
|
2118
2099
|
try {
|
|
2119
2100
|
const sqliteTasks = dl.db.prepare('SELECT id, description, project_slug, created_at, completed_at FROM tasks').all();
|
|
2120
2101
|
for (const t of sqliteTasks) {
|
|
2121
|
-
seenIds.add(t.id);
|
|
2122
2102
|
if (t.created_at) items.push({ kind: 'task', date: String(t.created_at).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.project_slug || '' });
|
|
2123
2103
|
if (t.completed_at) items.push({ kind: 'task', date: String(t.completed_at).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.project_slug || '' });
|
|
2124
2104
|
}
|
|
2125
|
-
} catch { /* db
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
// Legacy JSON fallback for tasks not yet in SQLite
|
|
2129
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2130
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2131
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2132
|
-
for (const t of tasks) {
|
|
2133
|
-
if (seenIds.has(t.id)) continue; // already from SQLite
|
|
2134
|
-
if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
2135
|
-
if (t.completedAt) items.push({ kind: 'task', date: String(t.completedAt).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
2105
|
+
} catch { /* db not ready */ }
|
|
2136
2106
|
}
|
|
2137
2107
|
|
|
2138
2108
|
items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
@@ -2504,6 +2474,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2504
2474
|
return safeJson(res, 200, { ok: true, projects: items });
|
|
2505
2475
|
}
|
|
2506
2476
|
|
|
2477
|
+
if (req.url === '/api/projects/create') {
|
|
2478
|
+
const slug = String(payload.slug || '').trim();
|
|
2479
|
+
if (!slug) return safeJson(res, 400, { error: 'Missing slug' });
|
|
2480
|
+
const name = String(payload.name || slug).trim();
|
|
2481
|
+
const client = String(payload.client || '').trim() || null;
|
|
2482
|
+
|
|
2483
|
+
// Check if exists
|
|
2484
|
+
const existing = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2485
|
+
if (existing) return safeJson(res, 409, { error: 'Project already exists' });
|
|
2486
|
+
|
|
2487
|
+
dl.db.prepare("INSERT INTO projects (slug, client, name, is_active) VALUES (?, ?, ?, 1)").run(slug, client, name);
|
|
2488
|
+
|
|
2489
|
+
if (payload.statusText) {
|
|
2490
|
+
const proj = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2491
|
+
if (proj) {
|
|
2492
|
+
dl.db.prepare("INSERT INTO project_status_history (project_id, status_text, date) VALUES (?, ?, ?)").run(proj.id, String(payload.statusText).trim(), new Date().toISOString());
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
return safeJson(res, 200, { ok: true, project: { slug, name, client } });
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (req.url === '/api/projects/update') {
|
|
2500
|
+
const slug = String(payload.slug || '').trim();
|
|
2501
|
+
if (!slug) return safeJson(res, 400, { error: 'Missing slug' });
|
|
2502
|
+
|
|
2503
|
+
const existing = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2504
|
+
if (!existing) return safeJson(res, 404, { error: 'Project not found' });
|
|
2505
|
+
|
|
2506
|
+
const patch = payload.patch && typeof payload.patch === 'object' ? payload.patch : {};
|
|
2507
|
+
const queryUpdates = [];
|
|
2508
|
+
const params = [];
|
|
2509
|
+
|
|
2510
|
+
if (typeof patch.name === 'string') {
|
|
2511
|
+
queryUpdates.push('name = ?');
|
|
2512
|
+
params.push(patch.name.trim());
|
|
2513
|
+
}
|
|
2514
|
+
if (typeof patch.client === 'string') {
|
|
2515
|
+
queryUpdates.push('client = ?');
|
|
2516
|
+
params.push(patch.client.trim() || null);
|
|
2517
|
+
}
|
|
2518
|
+
if (typeof patch.isActive === 'boolean') {
|
|
2519
|
+
queryUpdates.push('is_active = ?');
|
|
2520
|
+
params.push(patch.isActive ? 1 : 0);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
if (queryUpdates.length > 0) {
|
|
2524
|
+
queryUpdates.push('updated_at = ?');
|
|
2525
|
+
params.push(new Date().toISOString());
|
|
2526
|
+
params.push(existing.id);
|
|
2527
|
+
dl.db.prepare(`UPDATE projects SET ${queryUpdates.join(', ')} WHERE id = ?`).run(...params);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Add status update if provided
|
|
2531
|
+
if (typeof patch.statusText === 'string' && patch.statusText.trim()) {
|
|
2532
|
+
dl.db.prepare("INSERT INTO project_status_history (project_id, status_text, date) VALUES (?, ?, ?)").run(existing.id, patch.statusText.trim(), new Date().toISOString());
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return safeJson(res, 200, { ok: true, project: { slug } });
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2507
2538
|
if (req.url === '/api/graph/data') {
|
|
2508
2539
|
const nodes = [];
|
|
2509
2540
|
const edges = [];
|
|
@@ -2574,9 +2605,12 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2574
2605
|
else if (it.kind === 'task') counts.taskCreated++;
|
|
2575
2606
|
}
|
|
2576
2607
|
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2608
|
+
let openBlockers = [];
|
|
2609
|
+
if (dl.db) {
|
|
2610
|
+
try {
|
|
2611
|
+
openBlockers = dl.db.prepare("SELECT id FROM blockers WHERE status = 'OPEN'").all();
|
|
2612
|
+
} catch { /* ignore */ }
|
|
2613
|
+
}
|
|
2580
2614
|
|
|
2581
2615
|
let summary = '';
|
|
2582
2616
|
if (!recent.length) {
|
|
@@ -2607,53 +2641,33 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2607
2641
|
|
|
2608
2642
|
const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
|
|
2609
2643
|
|
|
2610
|
-
// Tasks with projectSlug
|
|
2611
|
-
let tasksTotal = 0;
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
for (const t of tasks) {
|
|
2619
|
-
const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
|
|
2620
|
-
if (slug) tasksWithProject++;
|
|
2621
|
-
}
|
|
2644
|
+
// Tasks with projectSlug (from SQLite)
|
|
2645
|
+
let tasksTotal = 0, tasksWithProject = 0;
|
|
2646
|
+
if (dl.db) {
|
|
2647
|
+
try {
|
|
2648
|
+
const r = dl.db.prepare("SELECT COUNT(*) as total, SUM(CASE WHEN project_slug IS NOT NULL AND project_slug != '' THEN 1 ELSE 0 END) as withSlug FROM tasks").get();
|
|
2649
|
+
tasksTotal = r.total || 0;
|
|
2650
|
+
tasksWithProject = r.withSlug || 0;
|
|
2651
|
+
} catch { /* ignore */ }
|
|
2622
2652
|
}
|
|
2623
2653
|
|
|
2624
|
-
//
|
|
2625
|
-
let statusTotal = 0;
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
const dirp = stack.pop();
|
|
2632
|
-
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2633
|
-
for (const ent of entries) {
|
|
2634
|
-
const full = path.join(dirp, ent.name);
|
|
2635
|
-
if (ent.isDirectory()) stack.push(full);
|
|
2636
|
-
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2637
|
-
statusTotal++;
|
|
2638
|
-
const doc = readJsonOrNull(full) || {};
|
|
2639
|
-
if (Array.isArray(doc.history)) statusWithHistory++;
|
|
2640
|
-
}
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2654
|
+
// Projects with status history (from SQLite)
|
|
2655
|
+
let statusTotal = 0, statusWithHistory = 0;
|
|
2656
|
+
if (dl.db) {
|
|
2657
|
+
try {
|
|
2658
|
+
statusTotal = (dl.db.prepare("SELECT COUNT(*) as c FROM projects WHERE is_active = 1").get() || {}).c || 0;
|
|
2659
|
+
statusWithHistory = (dl.db.prepare("SELECT COUNT(DISTINCT project_id) as c FROM project_status_history").get() || {}).c || 0;
|
|
2660
|
+
} catch { /* ignore */ }
|
|
2643
2661
|
}
|
|
2644
2662
|
|
|
2645
|
-
// Blockers with projectSlug
|
|
2646
|
-
let blockersTotal = 0;
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
for (const b of blockers) {
|
|
2654
|
-
const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
|
|
2655
|
-
if (slug) blockersWithProject++;
|
|
2656
|
-
}
|
|
2663
|
+
// Blockers with projectSlug (from SQLite)
|
|
2664
|
+
let blockersTotal = 0, blockersWithProject = 0;
|
|
2665
|
+
if (dl.db) {
|
|
2666
|
+
try {
|
|
2667
|
+
const r = dl.db.prepare("SELECT COUNT(*) as total, SUM(CASE WHEN project_slug IS NOT NULL AND project_slug != '' THEN 1 ELSE 0 END) as withSlug FROM blockers").get();
|
|
2668
|
+
blockersTotal = r.total || 0;
|
|
2669
|
+
blockersWithProject = r.withSlug || 0;
|
|
2670
|
+
} catch { /* ignore */ }
|
|
2657
2671
|
}
|
|
2658
2672
|
|
|
2659
2673
|
const breakdown = {
|
|
@@ -2678,37 +2692,28 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2678
2692
|
const now = Date.now();
|
|
2679
2693
|
|
|
2680
2694
|
const pendingByProject = {};
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
if (!t || t.status === 'COMPLETED') continue;
|
|
2687
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2688
|
-
if (!slug) continue;
|
|
2689
|
-
pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
|
|
2690
|
-
}
|
|
2695
|
+
if (dl.db) {
|
|
2696
|
+
try {
|
|
2697
|
+
const rows = dl.db.prepare("SELECT project_slug, COUNT(*) as cnt FROM tasks WHERE status != 'COMPLETED' AND status != 'ARCHIVED' AND project_slug IS NOT NULL AND project_slug != '' GROUP BY project_slug").all();
|
|
2698
|
+
for (const r of rows) pendingByProject[r.project_slug] = r.cnt;
|
|
2699
|
+
} catch { /* ignore */ }
|
|
2691
2700
|
}
|
|
2692
2701
|
|
|
2693
2702
|
const blockersByProject = {};
|
|
2694
2703
|
const oldestByProject = {};
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
if (ageDays < daysThreshold) continue;
|
|
2709
|
-
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2710
|
-
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2711
|
-
}
|
|
2704
|
+
if (dl.db) {
|
|
2705
|
+
try {
|
|
2706
|
+
const rows = dl.db.prepare("SELECT project_slug, created_at FROM blockers WHERE (status = 'OPEN' OR status = 'MITIGATING') AND project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2707
|
+
for (const r of rows) {
|
|
2708
|
+
const slug = r.project_slug;
|
|
2709
|
+
const createdAt = r.created_at ? Date.parse(r.created_at) : null;
|
|
2710
|
+
if (!createdAt) continue;
|
|
2711
|
+
const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
|
|
2712
|
+
if (ageDays < daysThreshold) continue;
|
|
2713
|
+
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2714
|
+
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2715
|
+
}
|
|
2716
|
+
} catch { /* ignore */ }
|
|
2712
2717
|
}
|
|
2713
2718
|
|
|
2714
2719
|
const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
|
|
@@ -2733,44 +2738,25 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2733
2738
|
if (req.url === '/api/anomalies') {
|
|
2734
2739
|
const anomalies = {
|
|
2735
2740
|
tasksMissingProject: { count: 0, samples: [] },
|
|
2736
|
-
|
|
2741
|
+
projectsMissingHistory: { count: 0, samples: [] }
|
|
2737
2742
|
};
|
|
2738
2743
|
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
if (exists(base)) {
|
|
2756
|
-
const stack = [base];
|
|
2757
|
-
while (stack.length) {
|
|
2758
|
-
const dirp = stack.pop();
|
|
2759
|
-
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2760
|
-
for (const ent of entries) {
|
|
2761
|
-
const full = path.join(dirp, ent.name);
|
|
2762
|
-
if (ent.isDirectory()) stack.push(full);
|
|
2763
|
-
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2764
|
-
const doc = readJsonOrNull(full) || {};
|
|
2765
|
-
if (!Array.isArray(doc.history)) {
|
|
2766
|
-
anomalies.statusMissingHistory.count++;
|
|
2767
|
-
if (anomalies.statusMissingHistory.samples.length < 5) {
|
|
2768
|
-
anomalies.statusMissingHistory.samples.push(path.relative(workspaceDir, full).replace(/\\/g, '/'));
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2744
|
+
if (dl.db) {
|
|
2745
|
+
try {
|
|
2746
|
+
// Tasks without project slug
|
|
2747
|
+
const noSlug = dl.db.prepare("SELECT id, description FROM tasks WHERE project_slug IS NULL OR project_slug = ''").all();
|
|
2748
|
+
anomalies.tasksMissingProject.count = noSlug.length;
|
|
2749
|
+
anomalies.tasksMissingProject.samples = noSlug.slice(0, 5).map(t => `tasks::${t.id || t.description || 'task'}`);
|
|
2750
|
+
|
|
2751
|
+
// Active projects without any status history
|
|
2752
|
+
const noHistory = dl.db.prepare(`
|
|
2753
|
+
SELECT p.slug FROM projects p
|
|
2754
|
+
WHERE p.is_active = 1
|
|
2755
|
+
AND NOT EXISTS (SELECT 1 FROM project_status_history psh WHERE psh.project_id = p.id)
|
|
2756
|
+
`).all();
|
|
2757
|
+
anomalies.projectsMissingHistory.count = noHistory.length;
|
|
2758
|
+
anomalies.projectsMissingHistory.samples = noHistory.slice(0, 5).map(p => `projects::${p.slug}`);
|
|
2759
|
+
} catch { /* ignore */ }
|
|
2774
2760
|
}
|
|
2775
2761
|
|
|
2776
2762
|
return safeJson(res, 200, { ok: true, anomalies });
|
|
@@ -2784,88 +2770,52 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2784
2770
|
const now = Date.now();
|
|
2785
2771
|
const items = [];
|
|
2786
2772
|
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
2804
|
-
if (ageDays > 14) { // 14 days without an update is a risk
|
|
2805
|
-
items.push({
|
|
2806
|
-
type: 'stale_project',
|
|
2807
|
-
severity: ageDays > 30 ? 'high' : 'medium',
|
|
2808
|
-
slug: slug,
|
|
2809
|
-
message: `Projeto Inativo (${ageDays} dias sem update)`
|
|
2810
|
-
});
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2773
|
+
if (dl.db) {
|
|
2774
|
+
try {
|
|
2775
|
+
// 1. Stale projects (no status update in >14 days)
|
|
2776
|
+
const projects = dl.db.prepare(`
|
|
2777
|
+
SELECT p.slug, MAX(psh.date) as last_update
|
|
2778
|
+
FROM projects p
|
|
2779
|
+
LEFT JOIN project_status_history psh ON psh.project_id = p.id
|
|
2780
|
+
WHERE p.is_active = 1
|
|
2781
|
+
GROUP BY p.slug
|
|
2782
|
+
`).all();
|
|
2783
|
+
for (const p of projects) {
|
|
2784
|
+
const lastUpdated = p.last_update ? Date.parse(p.last_update) : null;
|
|
2785
|
+
if (lastUpdated) {
|
|
2786
|
+
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
2787
|
+
if (ageDays > 14) {
|
|
2788
|
+
items.push({ type: 'stale_project', severity: ageDays > 30 ? 'high' : 'medium', slug: p.slug, message: `Projeto Inativo (${ageDays} dias sem update)` });
|
|
2813
2789
|
}
|
|
2814
2790
|
}
|
|
2815
2791
|
}
|
|
2816
|
-
}
|
|
2817
|
-
}
|
|
2818
2792
|
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
const slug = String(b.projectSlug || '').trim();
|
|
2828
|
-
if (slug) {
|
|
2829
|
-
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2830
|
-
}
|
|
2831
|
-
}
|
|
2832
|
-
}
|
|
2833
|
-
for (const [slug, count] of Object.entries(blockersByProject)) {
|
|
2834
|
-
if (count >= 3) {
|
|
2835
|
-
items.push({
|
|
2836
|
-
type: 'blocker_concentration',
|
|
2837
|
-
severity: count > 5 ? 'high' : 'medium',
|
|
2838
|
-
slug: slug,
|
|
2839
|
-
message: `Concentração de Bloqueios (${count} abertos)`
|
|
2840
|
-
});
|
|
2793
|
+
// 2. Blocker concentration (>=3 open blockers per project)
|
|
2794
|
+
const blockerConc = dl.db.prepare(`
|
|
2795
|
+
SELECT project_slug, COUNT(*) as cnt FROM blockers
|
|
2796
|
+
WHERE status = 'OPEN' AND project_slug IS NOT NULL AND project_slug != ''
|
|
2797
|
+
GROUP BY project_slug HAVING cnt >= 3
|
|
2798
|
+
`).all();
|
|
2799
|
+
for (const r of blockerConc) {
|
|
2800
|
+
items.push({ type: 'blocker_concentration', severity: r.cnt > 5 ? 'high' : 'medium', slug: r.project_slug, message: `Concentração de Bloqueios (${r.cnt} abertos)` });
|
|
2841
2801
|
}
|
|
2842
|
-
}
|
|
2843
|
-
}
|
|
2844
2802
|
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2854
|
-
if (slug) {
|
|
2855
|
-
tasksByProject[slug] = (tasksByProject[slug] || 0) + 1;
|
|
2803
|
+
// 3. Task overload (>=5 HIGH priority pending tasks per project)
|
|
2804
|
+
const tasks = dl.db.prepare("SELECT project_slug, metadata FROM tasks WHERE status != 'COMPLETED' AND status != 'ARCHIVED' AND project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2805
|
+
const highByProject = {};
|
|
2806
|
+
for (const t of tasks) {
|
|
2807
|
+
let meta = {};
|
|
2808
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2809
|
+
if (String(meta.priority || '').toUpperCase() === 'HIGH') {
|
|
2810
|
+
highByProject[t.project_slug] = (highByProject[t.project_slug] || 0) + 1;
|
|
2856
2811
|
}
|
|
2857
2812
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
type: 'task_overload',
|
|
2863
|
-
severity: 'high',
|
|
2864
|
-
slug: slug,
|
|
2865
|
-
message: `Sobrecarga Crítica (${count} tarefas High-priority pendentes)`
|
|
2866
|
-
});
|
|
2813
|
+
for (const [slug, count] of Object.entries(highByProject)) {
|
|
2814
|
+
if (count >= 5) {
|
|
2815
|
+
items.push({ type: 'task_overload', severity: 'high', slug, message: `Sobrecarga Crítica (${count} tarefas High-priority pendentes)` });
|
|
2816
|
+
}
|
|
2867
2817
|
}
|
|
2868
|
-
}
|
|
2818
|
+
} catch { /* ignore */ }
|
|
2869
2819
|
}
|
|
2870
2820
|
|
|
2871
2821
|
items.sort((a, b) => {
|
|
@@ -2884,84 +2834,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2884
2834
|
}
|
|
2885
2835
|
|
|
2886
2836
|
const now = Date.now();
|
|
2887
|
-
const projectMap = {};
|
|
2888
|
-
|
|
2889
|
-
// 1. Load tasks
|
|
2890
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2891
|
-
if (exists(taskFile)) {
|
|
2892
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2893
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2894
|
-
|
|
2895
|
-
for (const t of tasks) {
|
|
2896
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2897
|
-
if (!slug) continue;
|
|
2898
|
-
|
|
2899
|
-
if (!projectMap[slug]) {
|
|
2900
|
-
projectMap[slug] = {
|
|
2901
|
-
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2902
|
-
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2903
|
-
streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
|
|
2904
|
-
};
|
|
2905
|
-
}
|
|
2837
|
+
const projectMap = {};
|
|
2906
2838
|
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2839
|
+
if (dl.db) {
|
|
2840
|
+
try {
|
|
2841
|
+
// Tasks
|
|
2842
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, created_at, metadata FROM tasks WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2843
|
+
for (const t of tasks) {
|
|
2844
|
+
const slug = t.project_slug;
|
|
2845
|
+
if (!projectMap[slug]) {
|
|
2846
|
+
projectMap[slug] = {
|
|
2847
|
+
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2848
|
+
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2849
|
+
streams: new Set(), lastUpdateMs: 0, status: 'ON_TRACK'
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
projectMap[slug].totalTasks++;
|
|
2853
|
+
if (t.status === 'COMPLETED') projectMap[slug].completedTasks++;
|
|
2854
|
+
else projectMap[slug].pendingTasks++;
|
|
2913
2855
|
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
projectMap[slug].streams.add(streamSlug);
|
|
2917
|
-
}
|
|
2856
|
+
let meta = {};
|
|
2857
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2858
|
+
if (meta.streamSlug) projectMap[slug].streams.add(meta.streamSlug);
|
|
2918
2859
|
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
projectMap[slug].lastUpdateMs = taskTime;
|
|
2860
|
+
if (t.created_at) {
|
|
2861
|
+
const ts = Date.parse(t.created_at);
|
|
2862
|
+
if (ts > projectMap[slug].lastUpdateMs) projectMap[slug].lastUpdateMs = ts;
|
|
2923
2863
|
}
|
|
2924
2864
|
}
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
// 2. Load blockers
|
|
2929
|
-
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2930
|
-
if (exists(blockerFile)) {
|
|
2931
|
-
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2932
|
-
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
2933
|
-
|
|
2934
|
-
for (const b of blockers) {
|
|
2935
|
-
const slug = String(b.projectSlug || '').trim();
|
|
2936
|
-
if (!slug) continue;
|
|
2937
|
-
|
|
2938
|
-
if (!projectMap[slug]) {
|
|
2939
|
-
projectMap[slug] = {
|
|
2940
|
-
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2941
|
-
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2942
|
-
streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
|
|
2943
|
-
};
|
|
2944
|
-
}
|
|
2945
2865
|
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2866
|
+
// Blockers
|
|
2867
|
+
const blockers = dl.db.prepare("SELECT project_slug, status, severity, created_at, metadata FROM blockers WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2868
|
+
for (const b of blockers) {
|
|
2869
|
+
const slug = b.project_slug;
|
|
2870
|
+
if (!projectMap[slug]) {
|
|
2871
|
+
projectMap[slug] = {
|
|
2872
|
+
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2873
|
+
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2874
|
+
streams: new Set(), lastUpdateMs: 0, status: 'ON_TRACK'
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
2878
|
+
projectMap[slug].openBlockers++;
|
|
2879
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
2880
|
+
if (projectMap[slug].blockersBySeverity[sev] !== undefined) projectMap[slug].blockersBySeverity[sev]++;
|
|
2881
|
+
}
|
|
2882
|
+
if (b.created_at) {
|
|
2883
|
+
const ts = Date.parse(b.created_at);
|
|
2884
|
+
if (ts > projectMap[slug].lastUpdateMs) projectMap[slug].lastUpdateMs = ts;
|
|
2951
2885
|
}
|
|
2952
2886
|
}
|
|
2953
2887
|
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
}
|
|
2888
|
+
// Enrich with project names from projects table
|
|
2889
|
+
const projRows = dl.db.prepare("SELECT slug, name FROM projects").all();
|
|
2890
|
+
for (const p of projRows) {
|
|
2891
|
+
if (projectMap[p.slug] && p.name) projectMap[p.slug].name = p.name;
|
|
2959
2892
|
}
|
|
2960
|
-
}
|
|
2893
|
+
} catch { /* ignore */ }
|
|
2961
2894
|
}
|
|
2962
2895
|
|
|
2963
|
-
// Helper: format time ago
|
|
2964
2896
|
const formatAgo = (ms) => {
|
|
2897
|
+
if (!ms) return 'never';
|
|
2965
2898
|
const age = now - ms;
|
|
2966
2899
|
const secs = Math.floor(age / 1000);
|
|
2967
2900
|
if (secs < 60) return 'just now';
|
|
@@ -2973,31 +2906,21 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2973
2906
|
return days + 'd ago';
|
|
2974
2907
|
};
|
|
2975
2908
|
|
|
2976
|
-
// Calculate status and completion rate for each project
|
|
2977
2909
|
const projects = [];
|
|
2978
2910
|
for (const [slug, proj] of Object.entries(projectMap)) {
|
|
2979
2911
|
const completionRate = proj.totalTasks > 0 ? Math.round((proj.completedTasks / proj.totalTasks) * 100) : 0;
|
|
2980
2912
|
let status = 'ON_TRACK';
|
|
2981
|
-
|
|
2982
2913
|
if (proj.blockersBySeverity.CRITICAL > 0) status = 'AT_RISK';
|
|
2983
2914
|
if (proj.blockersBySeverity.HIGH >= 3) status = 'AT_RISK';
|
|
2984
2915
|
if (proj.pendingTasks > 15) status = 'AT_RISK';
|
|
2985
|
-
|
|
2986
2916
|
const ageDays = Math.floor((now - proj.lastUpdateMs) / (24 * 60 * 60 * 1000));
|
|
2987
2917
|
if (ageDays > 14) status = 'IDLE';
|
|
2988
2918
|
|
|
2989
2919
|
projects.push({
|
|
2990
|
-
slug: proj.slug,
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
pendingTasks: proj.pendingTasks,
|
|
2995
|
-
completionRate,
|
|
2996
|
-
openBlockers: proj.openBlockers,
|
|
2997
|
-
blockersBySeverity: proj.blockersBySeverity,
|
|
2998
|
-
streams: Array.from(proj.streams),
|
|
2999
|
-
lastUpdateAgo: formatAgo(proj.lastUpdateMs),
|
|
3000
|
-
status
|
|
2920
|
+
slug: proj.slug, name: proj.name,
|
|
2921
|
+
totalTasks: proj.totalTasks, completedTasks: proj.completedTasks, pendingTasks: proj.pendingTasks,
|
|
2922
|
+
completionRate, openBlockers: proj.openBlockers, blockersBySeverity: proj.blockersBySeverity,
|
|
2923
|
+
streams: Array.from(proj.streams), lastUpdateAgo: formatAgo(proj.lastUpdateMs), status
|
|
3001
2924
|
});
|
|
3002
2925
|
}
|
|
3003
2926
|
|
|
@@ -3010,88 +2933,55 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3010
2933
|
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
3011
2934
|
}
|
|
3012
2935
|
|
|
3013
|
-
const breakdownMap = {};
|
|
3014
|
-
|
|
3015
|
-
// 1. Load tasks grouped by project & stream
|
|
3016
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
3017
|
-
if (exists(taskFile)) {
|
|
3018
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
3019
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
3020
|
-
|
|
3021
|
-
for (const t of tasks) {
|
|
3022
|
-
const pSlug = String(t.projectSlug || '').trim();
|
|
3023
|
-
const sSlug = String(t.streamSlug || 'default').trim();
|
|
3024
|
-
if (!pSlug) continue;
|
|
3025
|
-
|
|
3026
|
-
if (!breakdownMap[pSlug]) {
|
|
3027
|
-
breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
3028
|
-
}
|
|
3029
|
-
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
3030
|
-
breakdownMap[pSlug].streams[sSlug] = {
|
|
3031
|
-
streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
3032
|
-
blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
|
|
3033
|
-
};
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
breakdownMap[pSlug].streams[sSlug].totalTasks++;
|
|
3037
|
-
if (t.status === 'COMPLETED') {
|
|
3038
|
-
breakdownMap[pSlug].streams[sSlug].completedTasks++;
|
|
3039
|
-
} else {
|
|
3040
|
-
breakdownMap[pSlug].streams[sSlug].pendingTasks++;
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
// 2. Load blockers grouped by project & stream
|
|
3046
|
-
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
3047
|
-
if (exists(blockerFile)) {
|
|
3048
|
-
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
3049
|
-
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
2936
|
+
const breakdownMap = {};
|
|
3050
2937
|
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
2938
|
+
if (dl.db) {
|
|
2939
|
+
try {
|
|
2940
|
+
// Tasks grouped by project & stream
|
|
2941
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, metadata FROM tasks WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2942
|
+
for (const t of tasks) {
|
|
2943
|
+
const pSlug = t.project_slug;
|
|
2944
|
+
let meta = {};
|
|
2945
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2946
|
+
const sSlug = String(meta.streamSlug || 'default').trim();
|
|
2947
|
+
|
|
2948
|
+
if (!breakdownMap[pSlug]) breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
2949
|
+
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
2950
|
+
breakdownMap[pSlug].streams[sSlug] = { streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 } };
|
|
2951
|
+
}
|
|
2952
|
+
breakdownMap[pSlug].streams[sSlug].totalTasks++;
|
|
2953
|
+
if (t.status === 'COMPLETED') breakdownMap[pSlug].streams[sSlug].completedTasks++;
|
|
2954
|
+
else breakdownMap[pSlug].streams[sSlug].pendingTasks++;
|
|
3064
2955
|
}
|
|
3065
2956
|
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
2957
|
+
// Blockers grouped by project & stream
|
|
2958
|
+
const blockers = dl.db.prepare("SELECT project_slug, status, severity, metadata FROM blockers WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2959
|
+
for (const b of blockers) {
|
|
2960
|
+
const pSlug = b.project_slug;
|
|
2961
|
+
let meta = {};
|
|
2962
|
+
try { meta = b.metadata ? JSON.parse(b.metadata) : {}; } catch { meta = {}; }
|
|
2963
|
+
const sSlug = String(meta.streamSlug || 'default').trim();
|
|
2964
|
+
|
|
2965
|
+
if (!breakdownMap[pSlug]) breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
2966
|
+
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
2967
|
+
breakdownMap[pSlug].streams[sSlug] = { streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 } };
|
|
2968
|
+
}
|
|
2969
|
+
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
2970
|
+
breakdownMap[pSlug].streams[sSlug].blockersCount++;
|
|
2971
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
2972
|
+
if (breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev] !== undefined) breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev]++;
|
|
3071
2973
|
}
|
|
3072
2974
|
}
|
|
3073
|
-
}
|
|
2975
|
+
} catch { /* ignore */ }
|
|
3074
2976
|
}
|
|
3075
2977
|
|
|
3076
|
-
// Convert to array format
|
|
3077
2978
|
const breakdown = [];
|
|
3078
2979
|
for (const [pSlug, pData] of Object.entries(breakdownMap)) {
|
|
3079
2980
|
const streams = [];
|
|
3080
2981
|
for (const [sSlug, sData] of Object.entries(pData.streams)) {
|
|
3081
|
-
streams.push({
|
|
3082
|
-
streamName: sData.streamName,
|
|
3083
|
-
totalTasks: sData.totalTasks,
|
|
3084
|
-
completedTasks: sData.completedTasks,
|
|
3085
|
-
pendingTasks: sData.pendingTasks,
|
|
3086
|
-
blockersCount: sData.blockersCount,
|
|
3087
|
-
blockersBySeverity: sData.blockersBySeverity
|
|
3088
|
-
});
|
|
2982
|
+
streams.push({ streamName: sData.streamName, totalTasks: sData.totalTasks, completedTasks: sData.completedTasks, pendingTasks: sData.pendingTasks, blockersCount: sData.blockersCount, blockersBySeverity: sData.blockersBySeverity });
|
|
3089
2983
|
}
|
|
3090
|
-
breakdown.push({
|
|
3091
|
-
projectSlug: pSlug,
|
|
3092
|
-
projectName: pData.projectName,
|
|
3093
|
-
streams: streams.sort((a, b) => b.blockersCount - a.blockersCount)
|
|
3094
|
-
});
|
|
2984
|
+
breakdown.push({ projectSlug: pSlug, projectName: pData.projectName, streams: streams.sort((a, b) => b.blockersCount - a.blockersCount) });
|
|
3095
2985
|
}
|
|
3096
2986
|
|
|
3097
2987
|
return safeJson(res, 200, { ok: true, breakdown });
|
|
@@ -3106,70 +2996,54 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3106
2996
|
const now = Date.now();
|
|
3107
2997
|
const alerts = [];
|
|
3108
2998
|
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
for (const b of blockers) {
|
|
3116
|
-
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
3117
|
-
const createdTime = b.createdAt ? Date.parse(b.createdAt) : now;
|
|
2999
|
+
if (dl.db) {
|
|
3000
|
+
try {
|
|
3001
|
+
// 1. Open blockers with aging
|
|
3002
|
+
const blockers = dl.db.prepare("SELECT title, severity, project_slug, created_at, metadata FROM blockers WHERE status = 'OPEN'").all();
|
|
3003
|
+
for (const b of blockers) {
|
|
3004
|
+
const createdTime = b.created_at ? Date.parse(b.created_at) : now;
|
|
3118
3005
|
const ageDays = Math.floor((now - createdTime) / (24 * 60 * 60 * 1000));
|
|
3006
|
+
let meta = {};
|
|
3007
|
+
try { meta = b.metadata ? JSON.parse(b.metadata) : {}; } catch { meta = {}; }
|
|
3119
3008
|
|
|
3120
3009
|
let severity = 'MEDIUM';
|
|
3121
3010
|
if (String(b.severity || '').toUpperCase() === 'CRITICAL') severity = 'CRITICAL';
|
|
3122
3011
|
else if (ageDays > 7) severity = 'HIGH';
|
|
3123
3012
|
|
|
3124
3013
|
alerts.push({
|
|
3125
|
-
severity,
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
streamName: String(b.streamSlug || '').trim(),
|
|
3014
|
+
severity, type: 'old_blocker',
|
|
3015
|
+
projectSlug: String(b.project_slug || '').trim(),
|
|
3016
|
+
streamName: String(meta.streamSlug || '').trim(),
|
|
3129
3017
|
message: `Bloqueio: ${b.title} (${ageDays} dias)`,
|
|
3130
|
-
age: ageDays,
|
|
3131
|
-
createdAt: b.createdAt
|
|
3018
|
+
age: ageDays, createdAt: b.created_at
|
|
3132
3019
|
});
|
|
3133
3020
|
}
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
3021
|
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
const
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
severity: ageDays > 30 ? 'CRITICAL' : 'HIGH',
|
|
3157
|
-
type: 'stale_project',
|
|
3158
|
-
projectSlug: slug,
|
|
3159
|
-
streamName: '',
|
|
3160
|
-
message: `Projeto inativo por ${ageDays} dias`,
|
|
3161
|
-
age: ageDays,
|
|
3162
|
-
createdAt: doc.lastUpdated
|
|
3163
|
-
});
|
|
3164
|
-
}
|
|
3165
|
-
}
|
|
3022
|
+
// 2. Stale projects
|
|
3023
|
+
const projects = dl.db.prepare(`
|
|
3024
|
+
SELECT p.slug, MAX(psh.date) as last_update
|
|
3025
|
+
FROM projects p
|
|
3026
|
+
LEFT JOIN project_status_history psh ON psh.project_id = p.id
|
|
3027
|
+
WHERE p.is_active = 1
|
|
3028
|
+
GROUP BY p.slug
|
|
3029
|
+
`).all();
|
|
3030
|
+
for (const p of projects) {
|
|
3031
|
+
const lastUpdated = p.last_update ? Date.parse(p.last_update) : null;
|
|
3032
|
+
if (lastUpdated) {
|
|
3033
|
+
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
3034
|
+
if (ageDays > 14) {
|
|
3035
|
+
alerts.push({
|
|
3036
|
+
severity: ageDays > 30 ? 'CRITICAL' : 'HIGH', type: 'stale_project',
|
|
3037
|
+
projectSlug: p.slug, streamName: '',
|
|
3038
|
+
message: `Projeto inativo por ${ageDays} dias`,
|
|
3039
|
+
age: ageDays, createdAt: p.last_update
|
|
3040
|
+
});
|
|
3166
3041
|
}
|
|
3167
3042
|
}
|
|
3168
3043
|
}
|
|
3169
|
-
}
|
|
3044
|
+
} catch { /* ignore */ }
|
|
3170
3045
|
}
|
|
3171
3046
|
|
|
3172
|
-
// Sort by severity (CRITICAL > HIGH > MEDIUM) then by age
|
|
3173
3047
|
const severityOrder = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
|
3174
3048
|
alerts.sort((a, b) => {
|
|
3175
3049
|
const sA = severityOrder[a.severity] || 0;
|
|
@@ -3181,45 +3055,27 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3181
3055
|
return safeJson(res, 200, { ok: true, alerts });
|
|
3182
3056
|
}
|
|
3183
3057
|
|
|
3184
|
-
if (req.url === '/api/incidents/resolve') {
|
|
3185
|
-
const title = payload.title;
|
|
3186
|
-
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
3187
|
-
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
3188
|
-
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
3189
|
-
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
3190
|
-
const md = fs.readFileSync(p, 'utf8');
|
|
3191
|
-
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
3192
|
-
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
3193
|
-
fs.writeFileSync(p, updated, 'utf8');
|
|
3194
|
-
return safeJson(res, 200, { ok: true });
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
if (req.url === '/api/incidents') {
|
|
3198
|
-
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
3199
|
-
if (!exists(p)) return safeJson(res, 200, { ok: true, markdown: '' });
|
|
3200
|
-
const md = fs.readFileSync(p, 'utf8');
|
|
3201
|
-
return safeJson(res, 200, { ok: true, markdown: md });
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
3058
|
if (req.url === '/api/tasks/heatmap') {
|
|
3205
|
-
const file = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
3206
|
-
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
3207
|
-
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
3208
3059
|
const map = {};
|
|
3209
3060
|
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3061
|
+
|
|
3062
|
+
if (dl.db) {
|
|
3063
|
+
try {
|
|
3064
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, metadata FROM tasks").all();
|
|
3065
|
+
for (const t of tasks) {
|
|
3066
|
+
const slug = t.project_slug || 'unassigned';
|
|
3067
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
3068
|
+
map[slug].total++;
|
|
3069
|
+
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
3070
|
+
let meta = {};
|
|
3071
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
3072
|
+
const p = normalizePriority(meta.priority);
|
|
3073
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
3074
|
+
}
|
|
3075
|
+
} catch { /* ignore */ }
|
|
3217
3076
|
}
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
3221
|
-
return { slug, ...v, linkRel };
|
|
3222
|
-
});
|
|
3077
|
+
|
|
3078
|
+
const items = Object.entries(map).map(([slug, v]) => ({ slug, ...v }));
|
|
3223
3079
|
items.sort((a, b) => b.total - a.total);
|
|
3224
3080
|
return safeJson(res, 200, { ok: true, items });
|
|
3225
3081
|
}
|
|
@@ -4173,6 +4029,30 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4173
4029
|
queryUpdates.push('project_slug = ?');
|
|
4174
4030
|
params.push(patch.projectSlug.trim() || null);
|
|
4175
4031
|
}
|
|
4032
|
+
if (typeof patch.owner === 'string') {
|
|
4033
|
+
queryUpdates.push('owner = ?');
|
|
4034
|
+
params.push(patch.owner.trim() || null);
|
|
4035
|
+
}
|
|
4036
|
+
if (typeof patch.nextAction === 'string') {
|
|
4037
|
+
queryUpdates.push('next_action = ?');
|
|
4038
|
+
params.push(patch.nextAction.trim() || null);
|
|
4039
|
+
}
|
|
4040
|
+
if (typeof patch.severity === 'string') {
|
|
4041
|
+
queryUpdates.push('severity = ?');
|
|
4042
|
+
params.push(patch.severity.trim().toUpperCase());
|
|
4043
|
+
}
|
|
4044
|
+
if (typeof patch.status === 'string') {
|
|
4045
|
+
queryUpdates.push('status = ?');
|
|
4046
|
+
params.push(patch.status.trim().toUpperCase());
|
|
4047
|
+
if (patch.status.toUpperCase() === 'RESOLVED') {
|
|
4048
|
+
queryUpdates.push('resolved_at = ?');
|
|
4049
|
+
params.push(new Date().toISOString());
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
if (typeof patch.title === 'string') {
|
|
4053
|
+
queryUpdates.push('title = ?');
|
|
4054
|
+
params.push(patch.title.trim());
|
|
4055
|
+
}
|
|
4176
4056
|
|
|
4177
4057
|
if (queryUpdates.length === 0) return safeJson(res, 200, { ok: true, blocker: { id } });
|
|
4178
4058
|
|
|
@@ -4256,7 +4136,10 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4256
4136
|
dueDate: t.due_date || null,
|
|
4257
4137
|
projectSlug: t.project_slug,
|
|
4258
4138
|
priority: meta.priority,
|
|
4259
|
-
streamSlug: meta.streamSlug
|
|
4139
|
+
streamSlug: meta.streamSlug,
|
|
4140
|
+
comments: meta.comments || [],
|
|
4141
|
+
source: meta.source || null,
|
|
4142
|
+
metadata: meta
|
|
4260
4143
|
};
|
|
4261
4144
|
});
|
|
4262
4145
|
|
|
@@ -4265,15 +4148,23 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4265
4148
|
ORDER BY
|
|
4266
4149
|
CASE severity WHEN 'CRITICAL' THEN 0 WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 ELSE 9 END ASC,
|
|
4267
4150
|
created_at ASC
|
|
4268
|
-
`).all().map(b =>
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4151
|
+
`).all().map(b => {
|
|
4152
|
+
let meta = {};
|
|
4153
|
+
try { meta = b.metadata ? JSON.parse(b.metadata) : {}; } catch { meta = {}; }
|
|
4154
|
+
return {
|
|
4155
|
+
id: b.id,
|
|
4156
|
+
title: b.title,
|
|
4157
|
+
severity: b.severity,
|
|
4158
|
+
status: b.status,
|
|
4159
|
+
projectSlug: b.project_slug,
|
|
4160
|
+
owner: b.owner,
|
|
4161
|
+
nextAction: b.next_action,
|
|
4162
|
+
createdAt: b.created_at,
|
|
4163
|
+
resolvedAt: b.resolved_at,
|
|
4164
|
+
source: meta.source || null,
|
|
4165
|
+
metadata: meta
|
|
4166
|
+
};
|
|
4167
|
+
});
|
|
4277
4168
|
|
|
4278
4169
|
return safeJson(res, 200, { ok: true, tasks, blockers: openBlockers });
|
|
4279
4170
|
}
|
|
@@ -4283,7 +4174,7 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4283
4174
|
if (!script) return safeJson(res, 400, { error: 'Missing script' });
|
|
4284
4175
|
|
|
4285
4176
|
// BUG-15: Whitelist allowed report scripts to prevent arbitrary npm run execution
|
|
4286
|
-
const ALLOWED_REPORT_SCRIPTS = new Set(['blockers', 'sm-weekly', 'status', 'daily', '
|
|
4177
|
+
const ALLOWED_REPORT_SCRIPTS = new Set(['blockers', 'sm-weekly', 'status', 'daily', 'build-index', 'update-index', 'export-obsidian']);
|
|
4287
4178
|
if (!ALLOWED_REPORT_SCRIPTS.has(script)) {
|
|
4288
4179
|
return safeJson(res, 400, { error: 'Script não permitido: ' + script });
|
|
4289
4180
|
}
|
|
@@ -4675,7 +4566,11 @@ function buildKanbanHtml(safeDefault, appVersion) {
|
|
|
4675
4566
|
</div>
|
|
4676
4567
|
<div class="qa-row">
|
|
4677
4568
|
<input id="qaSlug" class="qa-input" placeholder="Projeto (slug)" style="flex:1;" />
|
|
4678
|
-
<
|
|
4569
|
+
<div class="qa-date-wrap">
|
|
4570
|
+
<input id="qaDue" type="text" class="qa-input qa-date-text" placeholder="dd/mm/aaaa" maxlength="10" />
|
|
4571
|
+
<input id="qaDuePicker" type="date" class="qa-date-hidden" tabindex="-1" />
|
|
4572
|
+
<button type="button" class="qa-date-btn" onclick="var p=this.parentElement.querySelector('#qaDuePicker'); p.showPicker ? p.showPicker() : p.click();" title="Abrir calendario">📅</button>
|
|
4573
|
+
</div>
|
|
4679
4574
|
</div>
|
|
4680
4575
|
<div class="qa-row" style="justify-content:flex-end;">
|
|
4681
4576
|
<button class="btn small" type="button" onclick="window.closeQuickAdd()">Cancelar</button>
|