@cccarv82/freya 2.16.0 → 2.17.1

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