@cccarv82/freya 2.16.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.js +0 -78
- package/cli/web.js +349 -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 || '')));
|
|
@@ -2531,6 +2474,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2531
2474
|
return safeJson(res, 200, { ok: true, projects: items });
|
|
2532
2475
|
}
|
|
2533
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
|
+
|
|
2534
2538
|
if (req.url === '/api/graph/data') {
|
|
2535
2539
|
const nodes = [];
|
|
2536
2540
|
const edges = [];
|
|
@@ -2601,9 +2605,12 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2601
2605
|
else if (it.kind === 'task') counts.taskCreated++;
|
|
2602
2606
|
}
|
|
2603
2607
|
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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
|
+
}
|
|
2607
2614
|
|
|
2608
2615
|
let summary = '';
|
|
2609
2616
|
if (!recent.length) {
|
|
@@ -2634,53 +2641,33 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2634
2641
|
|
|
2635
2642
|
const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
|
|
2636
2643
|
|
|
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
|
-
}
|
|
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 */ }
|
|
2649
2652
|
}
|
|
2650
2653
|
|
|
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
|
-
}
|
|
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 */ }
|
|
2670
2661
|
}
|
|
2671
2662
|
|
|
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
|
-
}
|
|
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 */ }
|
|
2684
2671
|
}
|
|
2685
2672
|
|
|
2686
2673
|
const breakdown = {
|
|
@@ -2705,37 +2692,28 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2705
2692
|
const now = Date.now();
|
|
2706
2693
|
|
|
2707
2694
|
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
|
-
}
|
|
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 */ }
|
|
2718
2700
|
}
|
|
2719
2701
|
|
|
2720
2702
|
const blockersByProject = {};
|
|
2721
2703
|
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
|
-
}
|
|
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 */ }
|
|
2739
2717
|
}
|
|
2740
2718
|
|
|
2741
2719
|
const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
|
|
@@ -2760,44 +2738,25 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2760
2738
|
if (req.url === '/api/anomalies') {
|
|
2761
2739
|
const anomalies = {
|
|
2762
2740
|
tasksMissingProject: { count: 0, samples: [] },
|
|
2763
|
-
|
|
2741
|
+
projectsMissingHistory: { count: 0, samples: [] }
|
|
2764
2742
|
};
|
|
2765
2743
|
|
|
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
|
-
}
|
|
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 */ }
|
|
2801
2760
|
}
|
|
2802
2761
|
|
|
2803
2762
|
return safeJson(res, 200, { ok: true, anomalies });
|
|
@@ -2811,88 +2770,52 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2811
2770
|
const now = Date.now();
|
|
2812
2771
|
const items = [];
|
|
2813
2772
|
|
|
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
|
-
}
|
|
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)` });
|
|
2840
2789
|
}
|
|
2841
2790
|
}
|
|
2842
2791
|
}
|
|
2843
|
-
}
|
|
2844
|
-
}
|
|
2845
2792
|
|
|
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
|
-
}
|
|
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)` });
|
|
2858
2801
|
}
|
|
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
|
-
});
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
2802
|
|
|
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;
|
|
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;
|
|
2883
2811
|
}
|
|
2884
2812
|
}
|
|
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
|
-
});
|
|
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
|
+
}
|
|
2894
2817
|
}
|
|
2895
|
-
}
|
|
2818
|
+
} catch { /* ignore */ }
|
|
2896
2819
|
}
|
|
2897
2820
|
|
|
2898
2821
|
items.sort((a, b) => {
|
|
@@ -2911,84 +2834,67 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
2911
2834
|
}
|
|
2912
2835
|
|
|
2913
2836
|
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
|
-
}
|
|
2837
|
+
const projectMap = {};
|
|
2933
2838
|
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
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++;
|
|
2940
2855
|
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
projectMap[slug].streams.add(streamSlug);
|
|
2944
|
-
}
|
|
2856
|
+
let meta = {};
|
|
2857
|
+
try { meta = t.metadata ? JSON.parse(t.metadata) : {}; } catch { meta = {}; }
|
|
2858
|
+
if (meta.streamSlug) projectMap[slug].streams.add(meta.streamSlug);
|
|
2945
2859
|
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
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;
|
|
2950
2863
|
}
|
|
2951
2864
|
}
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
// 2. Load blockers
|
|
2956
|
-
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2957
|
-
if (exists(blockerFile)) {
|
|
2958
|
-
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2959
|
-
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
2960
|
-
|
|
2961
|
-
for (const b of blockers) {
|
|
2962
|
-
const slug = String(b.projectSlug || '').trim();
|
|
2963
|
-
if (!slug) continue;
|
|
2964
|
-
|
|
2965
|
-
if (!projectMap[slug]) {
|
|
2966
|
-
projectMap[slug] = {
|
|
2967
|
-
slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
|
|
2968
|
-
openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
2969
|
-
streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
|
|
2970
|
-
};
|
|
2971
|
-
}
|
|
2972
2865
|
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
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;
|
|
2978
2885
|
}
|
|
2979
2886
|
}
|
|
2980
2887
|
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
}
|
|
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;
|
|
2986
2892
|
}
|
|
2987
|
-
}
|
|
2893
|
+
} catch { /* ignore */ }
|
|
2988
2894
|
}
|
|
2989
2895
|
|
|
2990
|
-
// Helper: format time ago
|
|
2991
2896
|
const formatAgo = (ms) => {
|
|
2897
|
+
if (!ms) return 'never';
|
|
2992
2898
|
const age = now - ms;
|
|
2993
2899
|
const secs = Math.floor(age / 1000);
|
|
2994
2900
|
if (secs < 60) return 'just now';
|
|
@@ -3000,31 +2906,21 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3000
2906
|
return days + 'd ago';
|
|
3001
2907
|
};
|
|
3002
2908
|
|
|
3003
|
-
// Calculate status and completion rate for each project
|
|
3004
2909
|
const projects = [];
|
|
3005
2910
|
for (const [slug, proj] of Object.entries(projectMap)) {
|
|
3006
2911
|
const completionRate = proj.totalTasks > 0 ? Math.round((proj.completedTasks / proj.totalTasks) * 100) : 0;
|
|
3007
2912
|
let status = 'ON_TRACK';
|
|
3008
|
-
|
|
3009
2913
|
if (proj.blockersBySeverity.CRITICAL > 0) status = 'AT_RISK';
|
|
3010
2914
|
if (proj.blockersBySeverity.HIGH >= 3) status = 'AT_RISK';
|
|
3011
2915
|
if (proj.pendingTasks > 15) status = 'AT_RISK';
|
|
3012
|
-
|
|
3013
2916
|
const ageDays = Math.floor((now - proj.lastUpdateMs) / (24 * 60 * 60 * 1000));
|
|
3014
2917
|
if (ageDays > 14) status = 'IDLE';
|
|
3015
2918
|
|
|
3016
2919
|
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
|
|
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
|
|
3028
2924
|
});
|
|
3029
2925
|
}
|
|
3030
2926
|
|
|
@@ -3037,88 +2933,55 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3037
2933
|
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
3038
2934
|
}
|
|
3039
2935
|
|
|
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 : [];
|
|
2936
|
+
const breakdownMap = {};
|
|
3077
2937
|
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
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++;
|
|
3091
2955
|
}
|
|
3092
2956
|
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
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]++;
|
|
3098
2973
|
}
|
|
3099
2974
|
}
|
|
3100
|
-
}
|
|
2975
|
+
} catch { /* ignore */ }
|
|
3101
2976
|
}
|
|
3102
2977
|
|
|
3103
|
-
// Convert to array format
|
|
3104
2978
|
const breakdown = [];
|
|
3105
2979
|
for (const [pSlug, pData] of Object.entries(breakdownMap)) {
|
|
3106
2980
|
const streams = [];
|
|
3107
2981
|
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
|
-
});
|
|
2982
|
+
streams.push({ streamName: sData.streamName, totalTasks: sData.totalTasks, completedTasks: sData.completedTasks, pendingTasks: sData.pendingTasks, blockersCount: sData.blockersCount, blockersBySeverity: sData.blockersBySeverity });
|
|
3116
2983
|
}
|
|
3117
|
-
breakdown.push({
|
|
3118
|
-
projectSlug: pSlug,
|
|
3119
|
-
projectName: pData.projectName,
|
|
3120
|
-
streams: streams.sort((a, b) => b.blockersCount - a.blockersCount)
|
|
3121
|
-
});
|
|
2984
|
+
breakdown.push({ projectSlug: pSlug, projectName: pData.projectName, streams: streams.sort((a, b) => b.blockersCount - a.blockersCount) });
|
|
3122
2985
|
}
|
|
3123
2986
|
|
|
3124
2987
|
return safeJson(res, 200, { ok: true, breakdown });
|
|
@@ -3133,70 +2996,54 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3133
2996
|
const now = Date.now();
|
|
3134
2997
|
const alerts = [];
|
|
3135
2998
|
|
|
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;
|
|
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;
|
|
3145
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 = {}; }
|
|
3146
3008
|
|
|
3147
3009
|
let severity = 'MEDIUM';
|
|
3148
3010
|
if (String(b.severity || '').toUpperCase() === 'CRITICAL') severity = 'CRITICAL';
|
|
3149
3011
|
else if (ageDays > 7) severity = 'HIGH';
|
|
3150
3012
|
|
|
3151
3013
|
alerts.push({
|
|
3152
|
-
severity,
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
streamName: String(b.streamSlug || '').trim(),
|
|
3014
|
+
severity, type: 'old_blocker',
|
|
3015
|
+
projectSlug: String(b.project_slug || '').trim(),
|
|
3016
|
+
streamName: String(meta.streamSlug || '').trim(),
|
|
3156
3017
|
message: `Bloqueio: ${b.title} (${ageDays} dias)`,
|
|
3157
|
-
age: ageDays,
|
|
3158
|
-
createdAt: b.createdAt
|
|
3018
|
+
age: ageDays, createdAt: b.created_at
|
|
3159
3019
|
});
|
|
3160
3020
|
}
|
|
3161
|
-
}
|
|
3162
|
-
}
|
|
3163
3021
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
3193
3041
|
}
|
|
3194
3042
|
}
|
|
3195
3043
|
}
|
|
3196
|
-
}
|
|
3044
|
+
} catch { /* ignore */ }
|
|
3197
3045
|
}
|
|
3198
3046
|
|
|
3199
|
-
// Sort by severity (CRITICAL > HIGH > MEDIUM) then by age
|
|
3200
3047
|
const severityOrder = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
|
3201
3048
|
alerts.sort((a, b) => {
|
|
3202
3049
|
const sA = severityOrder[a.severity] || 0;
|
|
@@ -3208,45 +3055,27 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3208
3055
|
return safeJson(res, 200, { ok: true, alerts });
|
|
3209
3056
|
}
|
|
3210
3057
|
|
|
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
3058
|
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
3059
|
const map = {};
|
|
3236
3060
|
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
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 */ }
|
|
3244
3076
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
3248
|
-
return { slug, ...v, linkRel };
|
|
3249
|
-
});
|
|
3077
|
+
|
|
3078
|
+
const items = Object.entries(map).map(([slug, v]) => ({ slug, ...v }));
|
|
3250
3079
|
items.sort((a, b) => b.total - a.total);
|
|
3251
3080
|
return safeJson(res, 200, { ok: true, items });
|
|
3252
3081
|
}
|
|
@@ -4200,6 +4029,30 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4200
4029
|
queryUpdates.push('project_slug = ?');
|
|
4201
4030
|
params.push(patch.projectSlug.trim() || null);
|
|
4202
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
|
+
}
|
|
4203
4056
|
|
|
4204
4057
|
if (queryUpdates.length === 0) return safeJson(res, 200, { ok: true, blocker: { id } });
|
|
4205
4058
|
|
|
@@ -4321,7 +4174,7 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
4321
4174
|
if (!script) return safeJson(res, 400, { error: 'Missing script' });
|
|
4322
4175
|
|
|
4323
4176
|
// BUG-15: Whitelist allowed report scripts to prevent arbitrary npm run execution
|
|
4324
|
-
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']);
|
|
4325
4178
|
if (!ALLOWED_REPORT_SCRIPTS.has(script)) {
|
|
4326
4179
|
return safeJson(res, 400, { error: 'Script não permitido: ' + script });
|
|
4327
4180
|
}
|