@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.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
- const base = path.join(workspaceDir, 'data', 'Clients');
2115
- if (exists(base)) {
2116
- const stack = [base];
2117
- while (stack.length) {
2118
- const dirp = stack.pop();
2119
- const entries = fs.readdirSync(dirp, { withFileTypes: true });
2120
- for (const ent of entries) {
2121
- const full = path.join(dirp, ent.name);
2122
- if (ent.isDirectory()) stack.push(full);
2123
- else if (ent.isFile() && ent.name === 'status.json') {
2124
- const doc = readJsonOrNull(full) || {};
2125
- const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
2126
- const hist = Array.isArray(doc.history) ? doc.history : [];
2127
- for (const h of hist) {
2128
- items.push({
2129
- kind: 'status',
2130
- date: h.date || '',
2131
- title: `${doc.project || slug} (${h.type || 'Status'})`,
2132
- content: h.content || '',
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
- // BUG-33: Prefer SQLite tasks (primary source) over legacy task-log.json
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 may not be ready yet, fall through to legacy */ }
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
- const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2605
- const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2606
- const openBlockers = (blockerDoc.blockers || []).filter((b) => b && String(b.status || '').trim() === 'OPEN');
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
- let tasksWithProject = 0;
2640
- const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2641
- if (exists(taskFile)) {
2642
- const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2643
- const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2644
- tasksTotal = tasks.length;
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
- // Status files with history array
2652
- let statusTotal = 0;
2653
- let statusWithHistory = 0;
2654
- const base = path.join(workspaceDir, 'data', 'Clients');
2655
- if (exists(base)) {
2656
- const stack = [base];
2657
- while (stack.length) {
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
- let blockersWithProject = 0;
2675
- const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2676
- if (exists(blockersFile)) {
2677
- const blockersDoc = readJsonOrNull(blockersFile) || { blockers: [] };
2678
- const blockers = Array.isArray(blockersDoc.blockers) ? blockersDoc.blockers : [];
2679
- blockersTotal = blockers.length;
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
- const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2709
- if (exists(taskFile)) {
2710
- const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2711
- const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2712
- for (const t of tasks) {
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
- const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2723
- if (exists(blockerFile)) {
2724
- const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2725
- const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2726
- for (const b of blockers) {
2727
- if (!b) continue;
2728
- const status = String(b.status || '').toUpperCase();
2729
- if (status !== 'OPEN' && status !== 'MITIGATING') continue;
2730
- const slug = String(b.projectSlug || '').trim();
2731
- if (!slug) continue;
2732
- const createdAt = b.createdAt ? Date.parse(b.createdAt) : null;
2733
- if (!createdAt) continue;
2734
- const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
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
- statusMissingHistory: { count: 0, samples: [] }
2741
+ projectsMissingHistory: { count: 0, samples: [] }
2764
2742
  };
2765
2743
 
2766
- const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2767
- if (exists(taskFile)) {
2768
- const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2769
- const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2770
- for (const t of tasks) {
2771
- const slug = String(t.projectSlug || '').trim();
2772
- if (!slug) {
2773
- anomalies.tasksMissingProject.count++;
2774
- if (anomalies.tasksMissingProject.samples.length < 5) {
2775
- anomalies.tasksMissingProject.samples.push(`data/tasks/task-log.json::${t.id || t.description || 'task'}`);
2776
- }
2777
- }
2778
- }
2779
- }
2780
-
2781
- const base = path.join(workspaceDir, 'data', 'Clients');
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
- // 1. Inactive Projects (Stale)
2815
- const base = path.join(workspaceDir, 'data', 'Clients');
2816
- if (exists(base)) {
2817
- const stack = [base];
2818
- while (stack.length) {
2819
- const dirp = stack.pop();
2820
- const entries = fs.readdirSync(dirp, { withFileTypes: true });
2821
- for (const ent of entries) {
2822
- const full = path.join(dirp, ent.name);
2823
- if (ent.isDirectory()) stack.push(full);
2824
- else if (ent.isFile() && ent.name === 'status.json') {
2825
- const doc = readJsonOrNull(full) || {};
2826
- const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
2827
- if (doc.active !== false) {
2828
- const lastUpdated = doc.lastUpdated ? Date.parse(doc.lastUpdated) : null;
2829
- if (lastUpdated) {
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
- // 2. Blockers concentration
2847
- const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2848
- if (exists(blockerFile)) {
2849
- const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2850
- const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2851
- const blockersByProject = {};
2852
- for (const b of blockers) {
2853
- if (String(b.status || '').toUpperCase() === 'OPEN') {
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
- // 3. Task Overload
2873
- const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2874
- if (exists(taskFile)) {
2875
- const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2876
- const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2877
- const tasksByProject = {};
2878
- for (const t of tasks) {
2879
- if (t.status !== 'COMPLETED' && String(t.priority || '').toUpperCase() === 'HIGH') {
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
- for (const [slug, count] of Object.entries(tasksByProject)) {
2887
- if (count >= 5) {
2888
- items.push({
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 = {}; // projectSlug -> { name, totalTasks, completedTasks, pendingTasks, blockers, streams, lastUpdateAgo, status }
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
- projectMap[slug].totalTasks++;
2935
- if (t.status === 'COMPLETED') {
2936
- projectMap[slug].completedTasks++;
2937
- } else {
2938
- projectMap[slug].pendingTasks++;
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
- const streamSlug = String(t.streamSlug || '').trim();
2942
- if (streamSlug) {
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
- if (t.createdAt) {
2947
- const taskTime = Date.parse(t.createdAt);
2948
- if (taskTime > projectMap[slug].lastUpdateMs) {
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
- if (String(b.status || '').toUpperCase() === 'OPEN') {
2974
- projectMap[slug].openBlockers++;
2975
- const sev = String(b.severity || 'LOW').toUpperCase();
2976
- if (projectMap[slug].blockersBySeverity[sev] !== undefined) {
2977
- projectMap[slug].blockersBySeverity[sev]++;
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
- if (b.createdAt) {
2982
- const blockerTime = Date.parse(b.createdAt);
2983
- if (blockerTime > projectMap[slug].lastUpdateMs) {
2984
- projectMap[slug].lastUpdateMs = blockerTime;
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
- name: proj.name,
3019
- totalTasks: proj.totalTasks,
3020
- completedTasks: proj.completedTasks,
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 = {}; // projectSlug -> { projectName, streams: [...] }
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
- for (const b of blockers) {
3079
- const pSlug = String(b.projectSlug || '').trim();
3080
- const sSlug = String(b.streamSlug || 'default').trim();
3081
- if (!pSlug) continue;
3082
-
3083
- if (!breakdownMap[pSlug]) {
3084
- breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
3085
- }
3086
- if (!breakdownMap[pSlug].streams[sSlug]) {
3087
- breakdownMap[pSlug].streams[sSlug] = {
3088
- streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
3089
- blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
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
- if (String(b.status || '').toUpperCase() === 'OPEN') {
3094
- breakdownMap[pSlug].streams[sSlug].blockersCount++;
3095
- const sev = String(b.severity || 'LOW').toUpperCase();
3096
- if (breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev] !== undefined) {
3097
- breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev]++;
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
- // 1. Check for old blockers
3137
- const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
3138
- if (exists(blockerFile)) {
3139
- const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
3140
- const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
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
- type: 'old_blocker',
3154
- projectSlug: String(b.projectSlug || '').trim(),
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
- // 2. Check for stale projects
3165
- const base = path.join(workspaceDir, 'data', 'Clients');
3166
- if (exists(base)) {
3167
- const stack = [base];
3168
- while (stack.length) {
3169
- const dirp = stack.pop();
3170
- const entries = fs.readdirSync(dirp, { withFileTypes: true });
3171
- for (const ent of entries) {
3172
- const full = path.join(dirp, ent.name);
3173
- if (ent.isDirectory()) stack.push(full);
3174
- else if (ent.isFile() && ent.name === 'status.json') {
3175
- const doc = readJsonOrNull(full) || {};
3176
- const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
3177
- if (doc.active !== false) {
3178
- const lastUpdated = doc.lastUpdated ? Date.parse(doc.lastUpdated) : null;
3179
- if (lastUpdated) {
3180
- const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
3181
- if (ageDays > 14) {
3182
- alerts.push({
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
- for (const t of tasks) {
3238
- const slug = t.projectSlug || 'unassigned';
3239
- if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
3240
- map[slug].total++;
3241
- if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
3242
- const p = normalizePriority(t.priority || t.severity);
3243
- if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
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
- const items = Object.entries(map).map(([slug, v]) => {
3246
- const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
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', 'report', 'build-index', 'update-index', 'export-obsidian']);
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
  }