@cccarv82/freya 2.16.0 → 2.17.1
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/auto-update.js +2 -4
- package/cli/init.js +4 -5
- package/cli/web-ui.js +1 -79
- package/cli/web.js +359 -496
- 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 '';
|
|
@@ -2101,6 +2059,8 @@ function truncateText(text, maxLen) {
|
|
|
2101
2059
|
|
|
2102
2060
|
function getTimelineItems(workspaceDir) {
|
|
2103
2061
|
const items = [];
|
|
2062
|
+
|
|
2063
|
+
// Daily logs from filesystem (these are the raw markdown files)
|
|
2104
2064
|
const dailyDir = path.join(workspaceDir, 'logs', 'daily');
|
|
2105
2065
|
if (exists(dailyDir)) {
|
|
2106
2066
|
const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
@@ -2111,55 +2071,38 @@ function getTimelineItems(workspaceDir) {
|
|
|
2111
2071
|
items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
|
|
2112
2072
|
}
|
|
2113
2073
|
}
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
const
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
tags: h.tags || [],
|
|
2134
|
-
slug
|
|
2135
|
-
});
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
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
|
+
});
|
|
2138
2093
|
}
|
|
2139
|
-
}
|
|
2094
|
+
} catch { /* db not ready */ }
|
|
2140
2095
|
}
|
|
2141
2096
|
|
|
2142
|
-
//
|
|
2143
|
-
const seenIds = new Set();
|
|
2097
|
+
// Tasks from SQLite
|
|
2144
2098
|
if (dl.db) {
|
|
2145
2099
|
try {
|
|
2146
2100
|
const sqliteTasks = dl.db.prepare('SELECT id, description, project_slug, created_at, completed_at FROM tasks').all();
|
|
2147
2101
|
for (const t of sqliteTasks) {
|
|
2148
|
-
seenIds.add(t.id);
|
|
2149
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 || '' });
|
|
2150
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 || '' });
|
|
2151
2104
|
}
|
|
2152
|
-
} catch { /* db
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
// Legacy JSON fallback for tasks not yet in SQLite
|
|
2156
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2157
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2158
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2159
|
-
for (const t of tasks) {
|
|
2160
|
-
if (seenIds.has(t.id)) continue; // already from SQLite
|
|
2161
|
-
if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
2162
|
-
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 */ }
|
|
2163
2106
|
}
|
|
2164
2107
|
|
|
2165
2108
|
items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
@@ -2327,6 +2270,16 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2327
2270
|
await ready;
|
|
2328
2271
|
}
|
|
2329
2272
|
|
|
2273
|
+
// Auto-init workspace if not yet initialized (first run)
|
|
2274
|
+
if (!looksLikeFreyaWorkspace(wsDir)) {
|
|
2275
|
+
try {
|
|
2276
|
+
await initWorkspace({ targetDir: wsDir, force: false, forceData: false, forceLogs: false });
|
|
2277
|
+
console.log('[FREYA] Workspace initialized at', wsDir);
|
|
2278
|
+
} catch (e) {
|
|
2279
|
+
console.error('[FREYA] Warning: auto-init failed:', e.message || String(e));
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2330
2283
|
// Auto-update workspace scripts/deps if Freya version changed
|
|
2331
2284
|
try {
|
|
2332
2285
|
const { autoUpdate } = require('./auto-update');
|
|
@@ -2531,6 +2484,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2531
2484
|
return safeJson(res, 200, { ok: true, projects: items });
|
|
2532
2485
|
}
|
|
2533
2486
|
|
|
2487
|
+
if (req.url === '/api/projects/create') {
|
|
2488
|
+
const slug = String(payload.slug || '').trim();
|
|
2489
|
+
if (!slug) return safeJson(res, 400, { error: 'Missing slug' });
|
|
2490
|
+
const name = String(payload.name || slug).trim();
|
|
2491
|
+
const client = String(payload.client || '').trim() || null;
|
|
2492
|
+
|
|
2493
|
+
// Check if exists
|
|
2494
|
+
const existing = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2495
|
+
if (existing) return safeJson(res, 409, { error: 'Project already exists' });
|
|
2496
|
+
|
|
2497
|
+
dl.db.prepare("INSERT INTO projects (slug, client, name, is_active) VALUES (?, ?, ?, 1)").run(slug, client, name);
|
|
2498
|
+
|
|
2499
|
+
if (payload.statusText) {
|
|
2500
|
+
const proj = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2501
|
+
if (proj) {
|
|
2502
|
+
dl.db.prepare("INSERT INTO project_status_history (project_id, status_text, date) VALUES (?, ?, ?)").run(proj.id, String(payload.statusText).trim(), new Date().toISOString());
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
return safeJson(res, 200, { ok: true, project: { slug, name, client } });
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
if (req.url === '/api/projects/update') {
|
|
2510
|
+
const slug = String(payload.slug || '').trim();
|
|
2511
|
+
if (!slug) return safeJson(res, 400, { error: 'Missing slug' });
|
|
2512
|
+
|
|
2513
|
+
const existing = dl.db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
2514
|
+
if (!existing) return safeJson(res, 404, { error: 'Project not found' });
|
|
2515
|
+
|
|
2516
|
+
const patch = payload.patch && typeof payload.patch === 'object' ? payload.patch : {};
|
|
2517
|
+
const queryUpdates = [];
|
|
2518
|
+
const params = [];
|
|
2519
|
+
|
|
2520
|
+
if (typeof patch.name === 'string') {
|
|
2521
|
+
queryUpdates.push('name = ?');
|
|
2522
|
+
params.push(patch.name.trim());
|
|
2523
|
+
}
|
|
2524
|
+
if (typeof patch.client === 'string') {
|
|
2525
|
+
queryUpdates.push('client = ?');
|
|
2526
|
+
params.push(patch.client.trim() || null);
|
|
2527
|
+
}
|
|
2528
|
+
if (typeof patch.isActive === 'boolean') {
|
|
2529
|
+
queryUpdates.push('is_active = ?');
|
|
2530
|
+
params.push(patch.isActive ? 1 : 0);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
if (queryUpdates.length > 0) {
|
|
2534
|
+
queryUpdates.push('updated_at = ?');
|
|
2535
|
+
params.push(new Date().toISOString());
|
|
2536
|
+
params.push(existing.id);
|
|
2537
|
+
dl.db.prepare(`UPDATE projects SET ${queryUpdates.join(', ')} WHERE id = ?`).run(...params);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// Add status update if provided
|
|
2541
|
+
if (typeof patch.statusText === 'string' && patch.statusText.trim()) {
|
|
2542
|
+
dl.db.prepare("INSERT INTO project_status_history (project_id, status_text, date) VALUES (?, ?, ?)").run(existing.id, patch.statusText.trim(), new Date().toISOString());
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
return safeJson(res, 200, { ok: true, project: { slug } });
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2534
2548
|
if (req.url === '/api/graph/data') {
|
|
2535
2549
|
const nodes = [];
|
|
2536
2550
|
const edges = [];
|
|
@@ -2601,9 +2615,12 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2601
2615
|
else if (it.kind === 'task') counts.taskCreated++;
|
|
2602
2616
|
}
|
|
2603
2617
|
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2618
|
+
let openBlockers = [];
|
|
2619
|
+
if (dl.db) {
|
|
2620
|
+
try {
|
|
2621
|
+
openBlockers = dl.db.prepare("SELECT id FROM blockers WHERE status = 'OPEN'").all();
|
|
2622
|
+
} catch { /* ignore */ }
|
|
2623
|
+
}
|
|
2607
2624
|
|
|
2608
2625
|
let summary = '';
|
|
2609
2626
|
if (!recent.length) {
|
|
@@ -2634,53 +2651,33 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2634
2651
|
|
|
2635
2652
|
const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
|
|
2636
2653
|
|
|
2637
|
-
// Tasks with projectSlug
|
|
2638
|
-
let tasksTotal = 0;
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
for (const t of tasks) {
|
|
2646
|
-
const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
|
|
2647
|
-
if (slug) tasksWithProject++;
|
|
2648
|
-
}
|
|
2654
|
+
// Tasks with projectSlug (from SQLite)
|
|
2655
|
+
let tasksTotal = 0, tasksWithProject = 0;
|
|
2656
|
+
if (dl.db) {
|
|
2657
|
+
try {
|
|
2658
|
+
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();
|
|
2659
|
+
tasksTotal = r.total || 0;
|
|
2660
|
+
tasksWithProject = r.withSlug || 0;
|
|
2661
|
+
} catch { /* ignore */ }
|
|
2649
2662
|
}
|
|
2650
2663
|
|
|
2651
|
-
//
|
|
2652
|
-
let statusTotal = 0;
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
const dirp = stack.pop();
|
|
2659
|
-
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2660
|
-
for (const ent of entries) {
|
|
2661
|
-
const full = path.join(dirp, ent.name);
|
|
2662
|
-
if (ent.isDirectory()) stack.push(full);
|
|
2663
|
-
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2664
|
-
statusTotal++;
|
|
2665
|
-
const doc = readJsonOrNull(full) || {};
|
|
2666
|
-
if (Array.isArray(doc.history)) statusWithHistory++;
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2664
|
+
// Projects with status history (from SQLite)
|
|
2665
|
+
let statusTotal = 0, statusWithHistory = 0;
|
|
2666
|
+
if (dl.db) {
|
|
2667
|
+
try {
|
|
2668
|
+
statusTotal = (dl.db.prepare("SELECT COUNT(*) as c FROM projects WHERE is_active = 1").get() || {}).c || 0;
|
|
2669
|
+
statusWithHistory = (dl.db.prepare("SELECT COUNT(DISTINCT project_id) as c FROM project_status_history").get() || {}).c || 0;
|
|
2670
|
+
} catch { /* ignore */ }
|
|
2670
2671
|
}
|
|
2671
2672
|
|
|
2672
|
-
// Blockers with projectSlug
|
|
2673
|
-
let blockersTotal = 0;
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
for (const b of blockers) {
|
|
2681
|
-
const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
|
|
2682
|
-
if (slug) blockersWithProject++;
|
|
2683
|
-
}
|
|
2673
|
+
// Blockers with projectSlug (from SQLite)
|
|
2674
|
+
let blockersTotal = 0, blockersWithProject = 0;
|
|
2675
|
+
if (dl.db) {
|
|
2676
|
+
try {
|
|
2677
|
+
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();
|
|
2678
|
+
blockersTotal = r.total || 0;
|
|
2679
|
+
blockersWithProject = r.withSlug || 0;
|
|
2680
|
+
} catch { /* ignore */ }
|
|
2684
2681
|
}
|
|
2685
2682
|
|
|
2686
2683
|
const breakdown = {
|
|
@@ -2705,37 +2702,28 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2705
2702
|
const now = Date.now();
|
|
2706
2703
|
|
|
2707
2704
|
const pendingByProject = {};
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
if (!t || t.status === 'COMPLETED') continue;
|
|
2714
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2715
|
-
if (!slug) continue;
|
|
2716
|
-
pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
|
|
2717
|
-
}
|
|
2705
|
+
if (dl.db) {
|
|
2706
|
+
try {
|
|
2707
|
+
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();
|
|
2708
|
+
for (const r of rows) pendingByProject[r.project_slug] = r.cnt;
|
|
2709
|
+
} catch { /* ignore */ }
|
|
2718
2710
|
}
|
|
2719
2711
|
|
|
2720
2712
|
const blockersByProject = {};
|
|
2721
2713
|
const oldestByProject = {};
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
if (ageDays < daysThreshold) continue;
|
|
2736
|
-
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2737
|
-
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2738
|
-
}
|
|
2714
|
+
if (dl.db) {
|
|
2715
|
+
try {
|
|
2716
|
+
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();
|
|
2717
|
+
for (const r of rows) {
|
|
2718
|
+
const slug = r.project_slug;
|
|
2719
|
+
const createdAt = r.created_at ? Date.parse(r.created_at) : null;
|
|
2720
|
+
if (!createdAt) continue;
|
|
2721
|
+
const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
|
|
2722
|
+
if (ageDays < daysThreshold) continue;
|
|
2723
|
+
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2724
|
+
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2725
|
+
}
|
|
2726
|
+
} catch { /* ignore */ }
|
|
2739
2727
|
}
|
|
2740
2728
|
|
|
2741
2729
|
const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
|
|
@@ -2760,44 +2748,25 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2760
2748
|
if (req.url === '/api/anomalies') {
|
|
2761
2749
|
const anomalies = {
|
|
2762
2750
|
tasksMissingProject: { count: 0, samples: [] },
|
|
2763
|
-
|
|
2751
|
+
projectsMissingHistory: { count: 0, samples: [] }
|
|
2764
2752
|
};
|
|
2765
2753
|
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
if (exists(base)) {
|
|
2783
|
-
const stack = [base];
|
|
2784
|
-
while (stack.length) {
|
|
2785
|
-
const dirp = stack.pop();
|
|
2786
|
-
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2787
|
-
for (const ent of entries) {
|
|
2788
|
-
const full = path.join(dirp, ent.name);
|
|
2789
|
-
if (ent.isDirectory()) stack.push(full);
|
|
2790
|
-
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2791
|
-
const doc = readJsonOrNull(full) || {};
|
|
2792
|
-
if (!Array.isArray(doc.history)) {
|
|
2793
|
-
anomalies.statusMissingHistory.count++;
|
|
2794
|
-
if (anomalies.statusMissingHistory.samples.length < 5) {
|
|
2795
|
-
anomalies.statusMissingHistory.samples.push(path.relative(workspaceDir, full).replace(/\\/g, '/'));
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
}
|
|
2754
|
+
if (dl.db) {
|
|
2755
|
+
try {
|
|
2756
|
+
// Tasks without project slug
|
|
2757
|
+
const noSlug = dl.db.prepare("SELECT id, description FROM tasks WHERE project_slug IS NULL OR project_slug = ''").all();
|
|
2758
|
+
anomalies.tasksMissingProject.count = noSlug.length;
|
|
2759
|
+
anomalies.tasksMissingProject.samples = noSlug.slice(0, 5).map(t => `tasks::${t.id || t.description || 'task'}`);
|
|
2760
|
+
|
|
2761
|
+
// Active projects without any status history
|
|
2762
|
+
const noHistory = dl.db.prepare(`
|
|
2763
|
+
SELECT p.slug FROM projects p
|
|
2764
|
+
WHERE p.is_active = 1
|
|
2765
|
+
AND NOT EXISTS (SELECT 1 FROM project_status_history psh WHERE psh.project_id = p.id)
|
|
2766
|
+
`).all();
|
|
2767
|
+
anomalies.projectsMissingHistory.count = noHistory.length;
|
|
2768
|
+
anomalies.projectsMissingHistory.samples = noHistory.slice(0, 5).map(p => `projects::${p.slug}`);
|
|
2769
|
+
} catch { /* ignore */ }
|
|
2801
2770
|
}
|
|
2802
2771
|
|
|
2803
2772
|
return safeJson(res, 200, { ok: true, anomalies });
|
|
@@ -2811,88 +2780,52 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2811
2780
|
const now = Date.now();
|
|
2812
2781
|
const items = [];
|
|
2813
2782
|
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
2831
|
-
if (ageDays > 14) { // 14 days without an update is a risk
|
|
2832
|
-
items.push({
|
|
2833
|
-
type: 'stale_project',
|
|
2834
|
-
severity: ageDays > 30 ? 'high' : 'medium',
|
|
2835
|
-
slug: slug,
|
|
2836
|
-
message: `Projeto Inativo (${ageDays} dias sem update)`
|
|
2837
|
-
});
|
|
2838
|
-
}
|
|
2839
|
-
}
|
|
2783
|
+
if (dl.db) {
|
|
2784
|
+
try {
|
|
2785
|
+
// 1. Stale projects (no status update in >14 days)
|
|
2786
|
+
const projects = dl.db.prepare(`
|
|
2787
|
+
SELECT p.slug, MAX(psh.date) as last_update
|
|
2788
|
+
FROM projects p
|
|
2789
|
+
LEFT JOIN project_status_history psh ON psh.project_id = p.id
|
|
2790
|
+
WHERE p.is_active = 1
|
|
2791
|
+
GROUP BY p.slug
|
|
2792
|
+
`).all();
|
|
2793
|
+
for (const p of projects) {
|
|
2794
|
+
const lastUpdated = p.last_update ? Date.parse(p.last_update) : null;
|
|
2795
|
+
if (lastUpdated) {
|
|
2796
|
+
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
2797
|
+
if (ageDays > 14) {
|
|
2798
|
+
items.push({ type: 'stale_project', severity: ageDays > 30 ? 'high' : 'medium', slug: p.slug, message: `Projeto Inativo (${ageDays} dias sem update)` });
|
|
2840
2799
|
}
|
|
2841
2800
|
}
|
|
2842
2801
|
}
|
|
2843
|
-
}
|
|
2844
|
-
}
|
|
2845
2802
|
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
const slug = String(b.projectSlug || '').trim();
|
|
2855
|
-
if (slug) {
|
|
2856
|
-
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
|
-
for (const [slug, count] of Object.entries(blockersByProject)) {
|
|
2861
|
-
if (count >= 3) {
|
|
2862
|
-
items.push({
|
|
2863
|
-
type: 'blocker_concentration',
|
|
2864
|
-
severity: count > 5 ? 'high' : 'medium',
|
|
2865
|
-
slug: slug,
|
|
2866
|
-
message: `Concentração de Bloqueios (${count} abertos)`
|
|
2867
|
-
});
|
|
2803
|
+
// 2. Blocker concentration (>=3 open blockers per project)
|
|
2804
|
+
const blockerConc = dl.db.prepare(`
|
|
2805
|
+
SELECT project_slug, COUNT(*) as cnt FROM blockers
|
|
2806
|
+
WHERE status = 'OPEN' AND project_slug IS NOT NULL AND project_slug != ''
|
|
2807
|
+
GROUP BY project_slug HAVING cnt >= 3
|
|
2808
|
+
`).all();
|
|
2809
|
+
for (const r of blockerConc) {
|
|
2810
|
+
items.push({ type: 'blocker_concentration', severity: r.cnt > 5 ? 'high' : 'medium', slug: r.project_slug, message: `Concentração de Bloqueios (${r.cnt} abertos)` });
|
|
2868
2811
|
}
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
2812
|
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2881
|
-
if (slug) {
|
|
2882
|
-
tasksByProject[slug] = (tasksByProject[slug] || 0) + 1;
|
|
2813
|
+
// 3. Task overload (>=5 HIGH priority pending tasks per project)
|
|
2814
|
+
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();
|
|
2815
|
+
const highByProject = {};
|
|
2816
|
+
for (const t of tasks) {
|
|
2817
|
+
let meta = {};
|
|
2818
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2819
|
+
if (String(meta.priority || '').toUpperCase() === 'HIGH') {
|
|
2820
|
+
highByProject[t.project_slug] = (highByProject[t.project_slug] || 0) + 1;
|
|
2883
2821
|
}
|
|
2884
2822
|
}
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
type: 'task_overload',
|
|
2890
|
-
severity: 'high',
|
|
2891
|
-
slug: slug,
|
|
2892
|
-
message: `Sobrecarga Crítica (${count} tarefas High-priority pendentes)`
|
|
2893
|
-
});
|
|
2823
|
+
for (const [slug, count] of Object.entries(highByProject)) {
|
|
2824
|
+
if (count >= 5) {
|
|
2825
|
+
items.push({ type: 'task_overload', severity: 'high', slug, message: `Sobrecarga Crítica (${count} tarefas High-priority pendentes)` });
|
|
2826
|
+
}
|
|
2894
2827
|
}
|
|
2895
|
-
}
|
|
2828
|
+
} catch { /* ignore */ }
|
|
2896
2829
|
}
|
|
2897
2830
|
|
|
2898
2831
|
items.sort((a, b) => {
|
|
@@ -2911,84 +2844,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2911
2844
|
}
|
|
2912
2845
|
|
|
2913
2846
|
const now = Date.now();
|
|
2914
|
-
const projectMap = {};
|
|
2915
|
-
|
|
2916
|
-
// 1. Load tasks
|
|
2917
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2918
|
-
if (exists(taskFile)) {
|
|
2919
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2920
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2921
|
-
|
|
2922
|
-
for (const t of tasks) {
|
|
2923
|
-
const slug = String(t.projectSlug || '').trim();
|
|
2924
|
-
if (!slug) continue;
|
|
2925
|
-
|
|
2926
|
-
if (!projectMap[slug]) {
|
|
2927
|
-
projectMap[slug] = {
|
|
2928
|
-
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2929
|
-
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2930
|
-
streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
|
|
2931
|
-
};
|
|
2932
|
-
}
|
|
2847
|
+
const projectMap = {};
|
|
2933
2848
|
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2849
|
+
if (dl.db) {
|
|
2850
|
+
try {
|
|
2851
|
+
// Tasks
|
|
2852
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, created_at, metadata FROM tasks WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2853
|
+
for (const t of tasks) {
|
|
2854
|
+
const slug = t.project_slug;
|
|
2855
|
+
if (!projectMap[slug]) {
|
|
2856
|
+
projectMap[slug] = {
|
|
2857
|
+
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2858
|
+
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2859
|
+
streams: new Set(), lastUpdateMs: 0, status: 'ON_TRACK'
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
projectMap[slug].totalTasks++;
|
|
2863
|
+
if (t.status === 'COMPLETED') projectMap[slug].completedTasks++;
|
|
2864
|
+
else projectMap[slug].pendingTasks++;
|
|
2940
2865
|
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
projectMap[slug].streams.add(streamSlug);
|
|
2944
|
-
}
|
|
2866
|
+
let meta = {};
|
|
2867
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2868
|
+
if (meta.streamSlug) projectMap[slug].streams.add(meta.streamSlug);
|
|
2945
2869
|
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
projectMap[slug].lastUpdateMs = taskTime;
|
|
2870
|
+
if (t.created_at) {
|
|
2871
|
+
const ts = Date.parse(t.created_at);
|
|
2872
|
+
if (ts > projectMap[slug].lastUpdateMs) projectMap[slug].lastUpdateMs = ts;
|
|
2950
2873
|
}
|
|
2951
2874
|
}
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
2875
|
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
slug
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
}
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
projectMap[slug].openBlockers++;
|
|
2975
|
-
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
2976
|
-
if (projectMap[slug].blockersBySeverity[sev] !== undefined) {
|
|
2977
|
-
projectMap[slug].blockersBySeverity[sev]++;
|
|
2876
|
+
// Blockers
|
|
2877
|
+
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();
|
|
2878
|
+
for (const b of blockers) {
|
|
2879
|
+
const slug = b.project_slug;
|
|
2880
|
+
if (!projectMap[slug]) {
|
|
2881
|
+
projectMap[slug] = {
|
|
2882
|
+
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2883
|
+
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2884
|
+
streams: new Set(), lastUpdateMs: 0, status: 'ON_TRACK'
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
2888
|
+
projectMap[slug].openBlockers++;
|
|
2889
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
2890
|
+
if (projectMap[slug].blockersBySeverity[sev] !== undefined) projectMap[slug].blockersBySeverity[sev]++;
|
|
2891
|
+
}
|
|
2892
|
+
if (b.created_at) {
|
|
2893
|
+
const ts = Date.parse(b.created_at);
|
|
2894
|
+
if (ts > projectMap[slug].lastUpdateMs) projectMap[slug].lastUpdateMs = ts;
|
|
2978
2895
|
}
|
|
2979
2896
|
}
|
|
2980
2897
|
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
}
|
|
2898
|
+
// Enrich with project names from projects table
|
|
2899
|
+
const projRows = dl.db.prepare("SELECT slug, name FROM projects").all();
|
|
2900
|
+
for (const p of projRows) {
|
|
2901
|
+
if (projectMap[p.slug] && p.name) projectMap[p.slug].name = p.name;
|
|
2986
2902
|
}
|
|
2987
|
-
}
|
|
2903
|
+
} catch { /* ignore */ }
|
|
2988
2904
|
}
|
|
2989
2905
|
|
|
2990
|
-
// Helper: format time ago
|
|
2991
2906
|
const formatAgo = (ms) => {
|
|
2907
|
+
if (!ms) return 'never';
|
|
2992
2908
|
const age = now - ms;
|
|
2993
2909
|
const secs = Math.floor(age / 1000);
|
|
2994
2910
|
if (secs < 60) return 'just now';
|
|
@@ -3000,31 +2916,21 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3000
2916
|
return days + 'd ago';
|
|
3001
2917
|
};
|
|
3002
2918
|
|
|
3003
|
-
// Calculate status and completion rate for each project
|
|
3004
2919
|
const projects = [];
|
|
3005
2920
|
for (const [slug, proj] of Object.entries(projectMap)) {
|
|
3006
2921
|
const completionRate = proj.totalTasks > 0 ? Math.round((proj.completedTasks / proj.totalTasks) * 100) : 0;
|
|
3007
2922
|
let status = 'ON_TRACK';
|
|
3008
|
-
|
|
3009
2923
|
if (proj.blockersBySeverity.CRITICAL > 0) status = 'AT_RISK';
|
|
3010
2924
|
if (proj.blockersBySeverity.HIGH >= 3) status = 'AT_RISK';
|
|
3011
2925
|
if (proj.pendingTasks > 15) status = 'AT_RISK';
|
|
3012
|
-
|
|
3013
2926
|
const ageDays = Math.floor((now - proj.lastUpdateMs) / (24 * 60 * 60 * 1000));
|
|
3014
2927
|
if (ageDays > 14) status = 'IDLE';
|
|
3015
2928
|
|
|
3016
2929
|
projects.push({
|
|
3017
|
-
slug: proj.slug,
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
pendingTasks: proj.pendingTasks,
|
|
3022
|
-
completionRate,
|
|
3023
|
-
openBlockers: proj.openBlockers,
|
|
3024
|
-
blockersBySeverity: proj.blockersBySeverity,
|
|
3025
|
-
streams: Array.from(proj.streams),
|
|
3026
|
-
lastUpdateAgo: formatAgo(proj.lastUpdateMs),
|
|
3027
|
-
status
|
|
2930
|
+
slug: proj.slug, name: proj.name,
|
|
2931
|
+
totalTasks: proj.totalTasks, completedTasks: proj.completedTasks, pendingTasks: proj.pendingTasks,
|
|
2932
|
+
completionRate, openBlockers: proj.openBlockers, blockersBySeverity: proj.blockersBySeverity,
|
|
2933
|
+
streams: Array.from(proj.streams), lastUpdateAgo: formatAgo(proj.lastUpdateMs), status
|
|
3028
2934
|
});
|
|
3029
2935
|
}
|
|
3030
2936
|
|
|
@@ -3037,88 +2943,55 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3037
2943
|
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
3038
2944
|
}
|
|
3039
2945
|
|
|
3040
|
-
const breakdownMap = {};
|
|
3041
|
-
|
|
3042
|
-
// 1. Load tasks grouped by project & stream
|
|
3043
|
-
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
3044
|
-
if (exists(taskFile)) {
|
|
3045
|
-
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
3046
|
-
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
3047
|
-
|
|
3048
|
-
for (const t of tasks) {
|
|
3049
|
-
const pSlug = String(t.projectSlug || '').trim();
|
|
3050
|
-
const sSlug = String(t.streamSlug || 'default').trim();
|
|
3051
|
-
if (!pSlug) continue;
|
|
3052
|
-
|
|
3053
|
-
if (!breakdownMap[pSlug]) {
|
|
3054
|
-
breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
3055
|
-
}
|
|
3056
|
-
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
3057
|
-
breakdownMap[pSlug].streams[sSlug] = {
|
|
3058
|
-
streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
3059
|
-
blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
|
|
3060
|
-
};
|
|
3061
|
-
}
|
|
3062
|
-
|
|
3063
|
-
breakdownMap[pSlug].streams[sSlug].totalTasks++;
|
|
3064
|
-
if (t.status === 'COMPLETED') {
|
|
3065
|
-
breakdownMap[pSlug].streams[sSlug].completedTasks++;
|
|
3066
|
-
} else {
|
|
3067
|
-
breakdownMap[pSlug].streams[sSlug].pendingTasks++;
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
}
|
|
3071
|
-
|
|
3072
|
-
// 2. Load blockers grouped by project & stream
|
|
3073
|
-
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
3074
|
-
if (exists(blockerFile)) {
|
|
3075
|
-
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
3076
|
-
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
3077
|
-
|
|
3078
|
-
for (const b of blockers) {
|
|
3079
|
-
const pSlug = String(b.projectSlug || '').trim();
|
|
3080
|
-
const sSlug = String(b.streamSlug || 'default').trim();
|
|
3081
|
-
if (!pSlug) continue;
|
|
2946
|
+
const breakdownMap = {};
|
|
3082
2947
|
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
};
|
|
2948
|
+
if (dl.db) {
|
|
2949
|
+
try {
|
|
2950
|
+
// Tasks grouped by project & stream
|
|
2951
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, metadata FROM tasks WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2952
|
+
for (const t of tasks) {
|
|
2953
|
+
const pSlug = t.project_slug;
|
|
2954
|
+
let meta = {};
|
|
2955
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2956
|
+
const sSlug = String(meta.streamSlug || 'default').trim();
|
|
2957
|
+
|
|
2958
|
+
if (!breakdownMap[pSlug]) breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
2959
|
+
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
2960
|
+
breakdownMap[pSlug].streams[sSlug] = { streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 } };
|
|
2961
|
+
}
|
|
2962
|
+
breakdownMap[pSlug].streams[sSlug].totalTasks++;
|
|
2963
|
+
if (t.status === 'COMPLETED') breakdownMap[pSlug].streams[sSlug].completedTasks++;
|
|
2964
|
+
else breakdownMap[pSlug].streams[sSlug].pendingTasks++;
|
|
3091
2965
|
}
|
|
3092
2966
|
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
2967
|
+
// Blockers grouped by project & stream
|
|
2968
|
+
const blockers = dl.db.prepare("SELECT project_slug, status, severity, metadata FROM blockers WHERE project_slug IS NOT NULL AND project_slug != ''").all();
|
|
2969
|
+
for (const b of blockers) {
|
|
2970
|
+
const pSlug = b.project_slug;
|
|
2971
|
+
let meta = {};
|
|
2972
|
+
try { meta = b.metadata ? JSON.parse(b.metadata) : {}; } catch { meta = {}; }
|
|
2973
|
+
const sSlug = String(meta.streamSlug || 'default').trim();
|
|
2974
|
+
|
|
2975
|
+
if (!breakdownMap[pSlug]) breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
|
|
2976
|
+
if (!breakdownMap[pSlug].streams[sSlug]) {
|
|
2977
|
+
breakdownMap[pSlug].streams[sSlug] = { streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 } };
|
|
2978
|
+
}
|
|
2979
|
+
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
2980
|
+
breakdownMap[pSlug].streams[sSlug].blockersCount++;
|
|
2981
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
2982
|
+
if (breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev] !== undefined) breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev]++;
|
|
3098
2983
|
}
|
|
3099
2984
|
}
|
|
3100
|
-
}
|
|
2985
|
+
} catch { /* ignore */ }
|
|
3101
2986
|
}
|
|
3102
2987
|
|
|
3103
|
-
// Convert to array format
|
|
3104
2988
|
const breakdown = [];
|
|
3105
2989
|
for (const [pSlug, pData] of Object.entries(breakdownMap)) {
|
|
3106
2990
|
const streams = [];
|
|
3107
2991
|
for (const [sSlug, sData] of Object.entries(pData.streams)) {
|
|
3108
|
-
streams.push({
|
|
3109
|
-
streamName: sData.streamName,
|
|
3110
|
-
totalTasks: sData.totalTasks,
|
|
3111
|
-
completedTasks: sData.completedTasks,
|
|
3112
|
-
pendingTasks: sData.pendingTasks,
|
|
3113
|
-
blockersCount: sData.blockersCount,
|
|
3114
|
-
blockersBySeverity: sData.blockersBySeverity
|
|
3115
|
-
});
|
|
2992
|
+
streams.push({ streamName: sData.streamName, totalTasks: sData.totalTasks, completedTasks: sData.completedTasks, pendingTasks: sData.pendingTasks, blockersCount: sData.blockersCount, blockersBySeverity: sData.blockersBySeverity });
|
|
3116
2993
|
}
|
|
3117
|
-
breakdown.push({
|
|
3118
|
-
projectSlug: pSlug,
|
|
3119
|
-
projectName: pData.projectName,
|
|
3120
|
-
streams: streams.sort((a, b) => b.blockersCount - a.blockersCount)
|
|
3121
|
-
});
|
|
2994
|
+
breakdown.push({ projectSlug: pSlug, projectName: pData.projectName, streams: streams.sort((a, b) => b.blockersCount - a.blockersCount) });
|
|
3122
2995
|
}
|
|
3123
2996
|
|
|
3124
2997
|
return safeJson(res, 200, { ok: true, breakdown });
|
|
@@ -3133,70 +3006,54 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3133
3006
|
const now = Date.now();
|
|
3134
3007
|
const alerts = [];
|
|
3135
3008
|
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
for (const b of blockers) {
|
|
3143
|
-
if (String(b.status || '').toUpperCase() === 'OPEN') {
|
|
3144
|
-
const createdTime = b.createdAt ? Date.parse(b.createdAt) : now;
|
|
3009
|
+
if (dl.db) {
|
|
3010
|
+
try {
|
|
3011
|
+
// 1. Open blockers with aging
|
|
3012
|
+
const blockers = dl.db.prepare("SELECT title, severity, project_slug, created_at, metadata FROM blockers WHERE status = 'OPEN'").all();
|
|
3013
|
+
for (const b of blockers) {
|
|
3014
|
+
const createdTime = b.created_at ? Date.parse(b.created_at) : now;
|
|
3145
3015
|
const ageDays = Math.floor((now - createdTime) / (24 * 60 * 60 * 1000));
|
|
3016
|
+
let meta = {};
|
|
3017
|
+
try { meta = b.metadata ? JSON.parse(b.metadata) : {}; } catch { meta = {}; }
|
|
3146
3018
|
|
|
3147
3019
|
let severity = 'MEDIUM';
|
|
3148
3020
|
if (String(b.severity || '').toUpperCase() === 'CRITICAL') severity = 'CRITICAL';
|
|
3149
3021
|
else if (ageDays > 7) severity = 'HIGH';
|
|
3150
3022
|
|
|
3151
3023
|
alerts.push({
|
|
3152
|
-
severity,
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
streamName: String(b.streamSlug || '').trim(),
|
|
3024
|
+
severity, type: 'old_blocker',
|
|
3025
|
+
projectSlug: String(b.project_slug || '').trim(),
|
|
3026
|
+
streamName: String(meta.streamSlug || '').trim(),
|
|
3156
3027
|
message: `Bloqueio: ${b.title} (${ageDays} dias)`,
|
|
3157
|
-
age: ageDays,
|
|
3158
|
-
createdAt: b.createdAt
|
|
3028
|
+
age: ageDays, createdAt: b.created_at
|
|
3159
3029
|
});
|
|
3160
3030
|
}
|
|
3161
|
-
}
|
|
3162
|
-
}
|
|
3163
3031
|
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
const
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
severity: ageDays > 30 ? 'CRITICAL' : 'HIGH',
|
|
3184
|
-
type: 'stale_project',
|
|
3185
|
-
projectSlug: slug,
|
|
3186
|
-
streamName: '',
|
|
3187
|
-
message: `Projeto inativo por ${ageDays} dias`,
|
|
3188
|
-
age: ageDays,
|
|
3189
|
-
createdAt: doc.lastUpdated
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3032
|
+
// 2. Stale projects
|
|
3033
|
+
const projects = dl.db.prepare(`
|
|
3034
|
+
SELECT p.slug, MAX(psh.date) as last_update
|
|
3035
|
+
FROM projects p
|
|
3036
|
+
LEFT JOIN project_status_history psh ON psh.project_id = p.id
|
|
3037
|
+
WHERE p.is_active = 1
|
|
3038
|
+
GROUP BY p.slug
|
|
3039
|
+
`).all();
|
|
3040
|
+
for (const p of projects) {
|
|
3041
|
+
const lastUpdated = p.last_update ? Date.parse(p.last_update) : null;
|
|
3042
|
+
if (lastUpdated) {
|
|
3043
|
+
const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
|
|
3044
|
+
if (ageDays > 14) {
|
|
3045
|
+
alerts.push({
|
|
3046
|
+
severity: ageDays > 30 ? 'CRITICAL' : 'HIGH', type: 'stale_project',
|
|
3047
|
+
projectSlug: p.slug, streamName: '',
|
|
3048
|
+
message: `Projeto inativo por ${ageDays} dias`,
|
|
3049
|
+
age: ageDays, createdAt: p.last_update
|
|
3050
|
+
});
|
|
3193
3051
|
}
|
|
3194
3052
|
}
|
|
3195
3053
|
}
|
|
3196
|
-
}
|
|
3054
|
+
} catch { /* ignore */ }
|
|
3197
3055
|
}
|
|
3198
3056
|
|
|
3199
|
-
// Sort by severity (CRITICAL > HIGH > MEDIUM) then by age
|
|
3200
3057
|
const severityOrder = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
|
3201
3058
|
alerts.sort((a, b) => {
|
|
3202
3059
|
const sA = severityOrder[a.severity] || 0;
|
|
@@ -3208,45 +3065,27 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3208
3065
|
return safeJson(res, 200, { ok: true, alerts });
|
|
3209
3066
|
}
|
|
3210
3067
|
|
|
3211
|
-
if (req.url === '/api/incidents/resolve') {
|
|
3212
|
-
const title = payload.title;
|
|
3213
|
-
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
3214
|
-
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
3215
|
-
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
3216
|
-
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
3217
|
-
const md = fs.readFileSync(p, 'utf8');
|
|
3218
|
-
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
3219
|
-
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
3220
|
-
fs.writeFileSync(p, updated, 'utf8');
|
|
3221
|
-
return safeJson(res, 200, { ok: true });
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
|
-
if (req.url === '/api/incidents') {
|
|
3225
|
-
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
3226
|
-
if (!exists(p)) return safeJson(res, 200, { ok: true, markdown: '' });
|
|
3227
|
-
const md = fs.readFileSync(p, 'utf8');
|
|
3228
|
-
return safeJson(res, 200, { ok: true, markdown: md });
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
3068
|
if (req.url === '/api/tasks/heatmap') {
|
|
3232
|
-
const file = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
3233
|
-
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
3234
|
-
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
3235
3069
|
const map = {};
|
|
3236
3070
|
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3071
|
+
|
|
3072
|
+
if (dl.db) {
|
|
3073
|
+
try {
|
|
3074
|
+
const tasks = dl.db.prepare("SELECT project_slug, status, metadata FROM tasks").all();
|
|
3075
|
+
for (const t of tasks) {
|
|
3076
|
+
const slug = t.project_slug || 'unassigned';
|
|
3077
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
3078
|
+
map[slug].total++;
|
|
3079
|
+
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
3080
|
+
let meta = {};
|
|
3081
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
3082
|
+
const p = normalizePriority(meta.priority);
|
|
3083
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
3084
|
+
}
|
|
3085
|
+
} catch { /* ignore */ }
|
|
3244
3086
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
3248
|
-
return { slug, ...v, linkRel };
|
|
3249
|
-
});
|
|
3087
|
+
|
|
3088
|
+
const items = Object.entries(map).map(([slug, v]) => ({ slug, ...v }));
|
|
3250
3089
|
items.sort((a, b) => b.total - a.total);
|
|
3251
3090
|
return safeJson(res, 200, { ok: true, items });
|
|
3252
3091
|
}
|
|
@@ -4200,6 +4039,30 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4200
4039
|
queryUpdates.push('project_slug = ?');
|
|
4201
4040
|
params.push(patch.projectSlug.trim() || null);
|
|
4202
4041
|
}
|
|
4042
|
+
if (typeof patch.owner === 'string') {
|
|
4043
|
+
queryUpdates.push('owner = ?');
|
|
4044
|
+
params.push(patch.owner.trim() || null);
|
|
4045
|
+
}
|
|
4046
|
+
if (typeof patch.nextAction === 'string') {
|
|
4047
|
+
queryUpdates.push('next_action = ?');
|
|
4048
|
+
params.push(patch.nextAction.trim() || null);
|
|
4049
|
+
}
|
|
4050
|
+
if (typeof patch.severity === 'string') {
|
|
4051
|
+
queryUpdates.push('severity = ?');
|
|
4052
|
+
params.push(patch.severity.trim().toUpperCase());
|
|
4053
|
+
}
|
|
4054
|
+
if (typeof patch.status === 'string') {
|
|
4055
|
+
queryUpdates.push('status = ?');
|
|
4056
|
+
params.push(patch.status.trim().toUpperCase());
|
|
4057
|
+
if (patch.status.toUpperCase() === 'RESOLVED') {
|
|
4058
|
+
queryUpdates.push('resolved_at = ?');
|
|
4059
|
+
params.push(new Date().toISOString());
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
if (typeof patch.title === 'string') {
|
|
4063
|
+
queryUpdates.push('title = ?');
|
|
4064
|
+
params.push(patch.title.trim());
|
|
4065
|
+
}
|
|
4203
4066
|
|
|
4204
4067
|
if (queryUpdates.length === 0) return safeJson(res, 200, { ok: true, blocker: { id } });
|
|
4205
4068
|
|
|
@@ -4321,7 +4184,7 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4321
4184
|
if (!script) return safeJson(res, 400, { error: 'Missing script' });
|
|
4322
4185
|
|
|
4323
4186
|
// BUG-15: Whitelist allowed report scripts to prevent arbitrary npm run execution
|
|
4324
|
-
const ALLOWED_REPORT_SCRIPTS = new Set(['blockers', 'sm-weekly', 'status', 'daily', '
|
|
4187
|
+
const ALLOWED_REPORT_SCRIPTS = new Set(['blockers', 'sm-weekly', 'status', 'daily', 'build-index', 'update-index', 'export-obsidian']);
|
|
4325
4188
|
if (!ALLOWED_REPORT_SCRIPTS.has(script)) {
|
|
4326
4189
|
return safeJson(res, 400, { error: 'Script não permitido: ' + script });
|
|
4327
4190
|
}
|