@cccarv82/freya 2.15.0 → 2.17.0

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