@cccarv82/freya 2.14.0 → 2.14.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-ui.css CHANGED
@@ -371,7 +371,6 @@ body {
371
371
  align-items: center;
372
372
  padding: 16px 20px 10px;
373
373
  background: transparent;
374
- flex-shrink: 0;
375
374
  }
376
375
 
377
376
  .brandLine {
@@ -593,10 +592,15 @@ body {
593
592
  gap: 18px;
594
593
  flex: 1;
595
594
  }
595
+
596
596
  .centerBody > * {
597
597
  flex-shrink: 0;
598
598
  }
599
599
 
600
+ .topbar {
601
+ flex-shrink: 0;
602
+ }
603
+
600
604
  .promptShell {
601
605
  display: flex;
602
606
  justify-content: center;
@@ -1501,69 +1505,78 @@ textarea:focus {
1501
1505
  animation: fadeIn 0.2s ease-out;
1502
1506
  }
1503
1507
 
1504
- /* ── Companion Dashboard Tabs ── */
1508
+ /* ── Companion Dashboard Styles ── */
1505
1509
  .companionTabs {
1506
1510
  display: flex;
1507
1511
  gap: 8px;
1508
1512
  margin: 16px 0;
1509
1513
  border-bottom: 1px solid var(--border);
1510
- padding-bottom: 8px;
1511
1514
  }
1512
1515
 
1513
1516
  .companionTabs .tab {
1514
1517
  padding: 8px 12px;
1515
1518
  background: transparent;
1516
1519
  border: none;
1520
+ border-bottom: 2px solid transparent;
1517
1521
  cursor: pointer;
1518
- color: var(--text);
1519
- font-size: 13px;
1520
- position: relative;
1521
- transition: color 0.2s ease;
1522
+ font-size: 12px;
1523
+ color: var(--textMuted);
1524
+ transition: color 0.2s, border-color 0.2s;
1522
1525
  }
1523
1526
 
1524
1527
  .companionTabs .tab:hover {
1525
- color: var(--accent);
1528
+ color: var(--text);
1526
1529
  }
1527
1530
 
1528
1531
  .companionTabs .tab.active {
1529
1532
  color: var(--accent);
1530
- border-bottom: 2px solid var(--accent);
1531
- padding-bottom: 6px;
1532
- margin-bottom: -10px;
1533
+ border-bottom-color: var(--accent);
1533
1534
  }
1534
1535
 
1535
- /* ── Consolidated View (4 KPI Cards) ── */
1536
1536
  .consolidatedView {
1537
1537
  display: grid;
1538
1538
  grid-template-columns: repeat(4, 1fr);
1539
1539
  gap: 16px;
1540
- margin-bottom: 24px;
1541
1540
  }
1542
1541
 
1543
1542
  .kpiCard {
1544
1543
  background: var(--paper);
1545
1544
  padding: 16px;
1546
1545
  border: 1px solid var(--border);
1546
+ display: flex;
1547
+ flex-direction: column;
1548
+ gap: 4px;
1547
1549
  }
1548
1550
 
1549
- /* ── Project Cards Grid ── */
1550
1551
  .projectCardsGrid {
1551
1552
  display: grid;
1552
1553
  grid-template-columns: repeat(2, 1fr);
1553
1554
  gap: 16px;
1554
- margin-bottom: 24px;
1555
+ margin: 16px 0;
1556
+ }
1557
+
1558
+ @media (max-width: 1200px) {
1559
+ .projectCardsGrid {
1560
+ grid-template-columns: 1fr;
1561
+ }
1562
+ .consolidatedView {
1563
+ grid-template-columns: repeat(2, 1fr);
1564
+ }
1555
1565
  }
1556
1566
 
1557
1567
  .projectCard {
1558
1568
  background: var(--paper);
1559
1569
  padding: 16px;
1560
1570
  border-left: 3px solid #4ade80;
1571
+ border: 1px solid var(--border);
1572
+ border-left: 3px solid #4ade80;
1561
1573
  cursor: pointer;
1562
- transition: box-shadow 0.2s ease;
1574
+ transition: background 0.2s, border-color 0.2s;
1563
1575
  }
1564
1576
 
1565
1577
  .projectCard:hover {
1566
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1578
+ background: rgba(255, 255, 255, 0.02);
1579
+ border-color: var(--accent);
1567
1580
  }
1568
1581
 
1569
1582
  .projectCard.at_risk {
@@ -1574,25 +1587,27 @@ textarea:focus {
1574
1587
  border-left-color: #666;
1575
1588
  }
1576
1589
 
1577
- /* ── Stream Item ── */
1578
1590
  .streamItem {
1579
- padding: 8px;
1591
+ padding: 12px;
1580
1592
  margin: 4px 0;
1581
1593
  border: 1px solid var(--border);
1582
- cursor: pointer;
1583
- transition: background 0.2s ease;
1594
+ background: transparent;
1595
+ transition: background 0.2s;
1596
+ font-size: 11px;
1584
1597
  }
1585
1598
 
1586
1599
  .streamItem:hover {
1587
- background: var(--bg);
1600
+ background: rgba(255, 255, 255, 0.01);
1588
1601
  }
1589
1602
 
1590
- /* ── Alert Item ── */
1591
1603
  .alertItem {
1592
1604
  padding: 12px;
1593
1605
  margin: 8px 0;
1594
1606
  border-left: 3px solid #ef4444;
1595
1607
  background: rgba(239, 68, 68, 0.05);
1608
+ border: 1px solid var(--border);
1609
+ border-left: 3px solid #ef4444;
1610
+ font-size: 11px;
1596
1611
  }
1597
1612
 
1598
1613
  * {
package/cli/web-ui.js CHANGED
@@ -139,10 +139,9 @@
139
139
  }
140
140
 
141
141
  const li = line.match(/^[ \t]*[-*][ \t]+(.*)$/);
142
- const oli = !li ? line.match(/^[ \t]*\d+\.[ \t]+(.*)$/) : null;
143
- if (li || oli) {
142
+ if (li) {
144
143
  if (!inList) { html += '<ul class="md-ul">'; inList = true; }
145
- const content = inlineFormat((li || oli)[1]);
144
+ const content = inlineFormat(li[1]);
146
145
  html += '<li>' + content + '</li>';
147
146
  continue;
148
147
  }
@@ -193,37 +192,42 @@
193
192
 
194
193
  // --- Strategy 2: regex fallback for truncated/malformed JSON ---
195
194
  var lines = [];
195
+ var num = 0;
196
196
 
197
197
  // Match append_daily_log / appenddailylog actions
198
198
  var logRe = /"type"\s*:\s*"append_?daily_?log"\s*,\s*"text"\s*:\s*"([^"]{1,300})/gi;
199
199
  var m;
200
200
  while ((m = logRe.exec(text)) !== null) {
201
+ num++;
201
202
  var t = m[1].slice(0, 140);
202
- lines.push('- \u{1F4DD} **Registrar no log:** ' + t + (m[1].length > 140 ? '...' : ''));
203
+ lines.push(num + '. \u{1F4DD} **Registrar no log:** ' + t + (m[1].length > 140 ? '...' : ''));
203
204
  }
204
205
 
205
206
  // Match create_task actions
206
207
  var taskRe = /"type"\s*:\s*"create_?task"\s*,\s*"description"\s*:\s*"([^"]{1,200})/gi;
207
208
  while ((m = taskRe.exec(text)) !== null) {
209
+ num++;
208
210
  var desc = m[1].slice(0, 120);
209
211
  var priMatch = text.slice(m.index, m.index + 400).match(/"priority"\s*:\s*"(\w+)"/i);
210
212
  var pri = priMatch ? ' (prioridade: **' + priMatch[1].toUpperCase() + '**)' : '';
211
- lines.push('- \u2705 **Criar tarefa:** ' + desc + pri);
213
+ lines.push(num + '. \u2705 **Criar tarefa:** ' + desc + pri);
212
214
  }
213
215
 
214
216
  // Match create_blocker actions
215
217
  var blockerRe = /"type"\s*:\s*"create_?blocker"\s*,\s*"title"\s*:\s*"([^"]{1,200})/gi;
216
218
  while ((m = blockerRe.exec(text)) !== null) {
219
+ num++;
217
220
  var title = m[1].slice(0, 120);
218
221
  var sevMatch = text.slice(m.index, m.index + 400).match(/"severity"\s*:\s*"(\w+)"/i);
219
222
  var sev = sevMatch ? ' (severidade: **' + sevMatch[1].toUpperCase() + '**)' : '';
220
- lines.push('- \u{1F6A7} **Registrar blocker:** ' + title + sev);
223
+ lines.push(num + '. \u{1F6A7} **Registrar blocker:** ' + title + sev);
221
224
  }
222
225
 
223
226
  // Match suggest_report actions
224
227
  var repRe = /"type"\s*:\s*"suggest_?report"\s*,\s*"name"\s*:\s*"([^"]+)"/gi;
225
228
  while ((m = repRe.exec(text)) !== null) {
226
- lines.push('- \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
229
+ num++;
230
+ lines.push(num + '. \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
227
231
  }
228
232
 
229
233
  return lines.length > 0 ? lines.join('\n') : null;
@@ -236,32 +240,33 @@
236
240
  oraclequery: '\u{1F50D}'
237
241
  };
238
242
 
239
- var lines = actions.map(function(a) {
243
+ var lines = actions.map(function(a, i) {
240
244
  var type = String(a.type || '').trim().toLowerCase().replace(/_/g, '');
241
245
  var icon = icons[type] || '\u2022';
246
+ var num = i + 1;
242
247
 
243
248
  if (type === 'appenddailylog') {
244
249
  var t = String(a.text || '').slice(0, 140);
245
- return '- ' + icon + ' **Registrar no log:** ' + t + (String(a.text || '').length > 140 ? '...' : '');
250
+ return num + '. ' + icon + ' **Registrar no log:** ' + t + (String(a.text || '').length > 140 ? '...' : '');
246
251
  }
247
252
  if (type === 'createtask') {
248
253
  var desc = String(a.description || '').slice(0, 120);
249
254
  var pri = a.priority ? ' (prioridade: **' + String(a.priority).toUpperCase() + '**)' : '';
250
255
  var cat = a.category ? ' [' + a.category + ']' : '';
251
- return '- ' + icon + ' **Criar tarefa:** ' + desc + pri + cat;
256
+ return num + '. ' + icon + ' **Criar tarefa:** ' + desc + pri + cat;
252
257
  }
253
258
  if (type === 'createblocker') {
254
259
  var title = String(a.title || a.description || '').slice(0, 120);
255
260
  var sev = a.severity ? ' (severidade: **' + String(a.severity).toUpperCase() + '**)' : '';
256
- return '- ' + icon + ' **Registrar blocker:** ' + title + sev;
261
+ return num + '. ' + icon + ' **Registrar blocker:** ' + title + sev;
257
262
  }
258
263
  if (type === 'suggestreport') {
259
- return '- ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
264
+ return num + '. ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
260
265
  }
261
266
  if (type === 'oraclequery') {
262
- return '- ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
267
+ return num + '. ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
263
268
  }
264
- return '- \u2022 **' + String(a.type || 'acao') + '**';
269
+ return num + '. \u2022 **' + String(a.type || 'acao') + '**';
265
270
  });
266
271
 
267
272
  return lines.join('\n');
@@ -2130,172 +2135,179 @@
2130
2135
  }
2131
2136
  }
2132
2137
 
2138
+ // New Companion Dashboard functions
2133
2139
  async function refreshCompanionDash() {
2140
+ const filter = document.querySelector('.companionTabs .tab.active')?.dataset?.filter || 'all';
2134
2141
  try {
2135
- setPill('run', 'carregando dashboard...');
2136
- const [pRes, sRes, aRes] = await Promise.all([
2142
+ const [prj, brk, alts] = await Promise.all([
2137
2143
  api('/api/companion/projects-summary', { dir: dirOrDefault() }),
2138
2144
  api('/api/companion/streams-breakdown', { dir: dirOrDefault() }),
2139
2145
  api('/api/companion/alerts', { dir: dirOrDefault() })
2140
2146
  ]);
2141
- if (!pRes || !pRes.ok || !sRes || !sRes.ok || !aRes || !aRes.ok) {
2142
- setPill('err', 'Falha ao carregar dashboard');
2143
- return;
2147
+
2148
+ const projects = (prj && prj.projects) || [];
2149
+ const breakdown = (brk && brk.breakdown) || [];
2150
+ const alerts = (alts && alts.alerts) || [];
2151
+
2152
+ // Show/hide sections based on filter
2153
+ $('consolidatedViewBox').style.display = filter === 'all' ? 'block' : 'none';
2154
+ $('projectCardsBox').style.display = filter === 'all' ? 'block' : 'none';
2155
+ $('streamBreakdownBox').style.display = filter === 'all' ? 'block' : 'none';
2156
+ $('alertsViewBox').style.display = filter === 'alerts' || filter === 'risk' ? 'block' : 'none';
2157
+
2158
+ if (filter === 'all' || filter === 'risk') {
2159
+ renderConsolidatedView(projects);
2160
+ renderProjectCards(projects, filter === 'risk');
2161
+ renderStreamBreakdown(breakdown, filter === 'risk');
2162
+ }
2163
+
2164
+ if (filter === 'alerts' || filter === 'risk') {
2165
+ renderAlerts(alerts, filter === 'risk');
2144
2166
  }
2145
- renderConsolidatedView(pRes.projects || []);
2146
- renderProjectCards(pRes.projects || []);
2147
- renderStreamBreakdown(sRes.items || []);
2148
- renderAlerts(aRes.alerts || []);
2149
- setPill('ok', 'dashboard pronto');
2167
+
2168
+ setPill('ok', 'dashboard atualizado');
2150
2169
  } catch (e) {
2151
- setPill('err', 'Erro ao carregar dashboard');
2170
+ setPill('err', 'falha ao carregar dashboard');
2152
2171
  console.error('refreshCompanionDash error:', e);
2153
2172
  }
2154
2173
  }
2155
2174
 
2156
2175
  function renderConsolidatedView(projects) {
2157
- const consolidated = $('companionConsolidated');
2158
- if (!consolidated) return;
2159
- let totalCompleted = 0, totalPending = 0, totalCritical = 0;
2160
- for (const p of projects) {
2161
- totalCompleted += p.completedTasks || 0;
2162
- totalPending += p.pendingTasks || 0;
2163
- totalCritical += (p.blockersBySeverity && p.blockersBySeverity.CRITICAL) || 0;
2164
- }
2165
- const avgVelocity = projects.length > 0 ? (projects.reduce((s, p) => s + (p.velocityThisWeek || 0), 0) / projects.length).toFixed(1) : '0';
2166
- const staleProjects = projects.filter(p => p.status === 'IDLE').length;
2167
- consolidated.innerHTML = `
2168
- <div class="kpiCard">
2169
- <div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Concluídas</div>
2170
- <div style="font-size:20px; font-weight:700;">✅ ${totalCompleted}</div>
2171
- </div>
2176
+ const box = $('consolidatedView');
2177
+ if (!box) return;
2178
+
2179
+ const totalCompleted = projects.reduce((sum, p) => sum + p.completedTasks, 0);
2180
+ const totalPending = projects.reduce((sum, p) => sum + p.pendingTasks, 0);
2181
+ const totalCritical = projects.reduce((sum, p) => sum + (p.blockersBySeverity?.CRITICAL || 0), 0);
2182
+ const totalPendingTasks = projects.reduce((sum, p) => sum + p.pendingTasks, 0);
2183
+ const overallCompletion = projects.reduce((sum, p) => sum + p.completionRate, 0) / Math.max(projects.length, 1);
2184
+
2185
+ const kpis = [
2186
+ { icon: '✅', label: 'Concluídas', value: totalCompleted, unit: 'semana' },
2187
+ { icon: '⏳', label: 'Pendentes', value: totalPending, unit: 'lembretes' },
2188
+ { icon: '🚧', label: 'CRITICAL', value: totalCritical, unit: 'bloqueios' },
2189
+ { icon: '📋', label: 'Taxa Conclusão', value: Math.round(overallCompletion), unit: '%' }
2190
+ ];
2191
+
2192
+ box.innerHTML = kpis.map(kpi => `
2172
2193
  <div class="kpiCard">
2173
- <div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Pendentes</div>
2174
- <div style="font-size:20px; font-weight:700;">⏳ ${totalPending}</div>
2194
+ <div style="font-size: 24px; margin-bottom: 4px;">${kpi.icon}</div>
2195
+ <div style="font-size: 28px; font-weight: 700; color: var(--accent);">${kpi.value}</div>
2196
+ <div style="font-size: 11px; color: var(--textMuted); margin-top: 4px;">${kpi.label}</div>
2197
+ <div style="font-size: 10px; color: var(--textMuted);">${kpi.unit}</div>
2175
2198
  </div>
2176
- <div class="kpiCard">
2177
- <div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">CRITICAL</div>
2178
- <div style="font-size:20px; font-weight:700; color:#ef4444;">🚧 ${totalCritical}</div>
2179
- </div>
2180
- <div class="kpiCard">
2181
- <div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Velocity</div>
2182
- <div style="font-size:20px; font-weight:700;">📈 ${avgVelocity}/dia</div>
2183
- </div>
2184
- `;
2199
+ `).join('');
2185
2200
  }
2186
2201
 
2187
- function renderProjectCards(projects) {
2188
- const grid = $('companionProjects');
2189
- if (!grid) return;
2190
- const cards = projects.map(p => {
2191
- const progressPct = p.totalTasks > 0 ? Math.round((p.completedTasks / p.totalTasks) * 100) : 0;
2192
- const statusColor = p.status === 'ON_TRACK' ? '#10b981' : p.status === 'AT_RISK' ? '#ff9900' : '#666';
2193
- const statusLabel = p.status === 'ON_TRACK' ? 'Em dia' : p.status === 'AT_RISK' ? 'Em risco' : 'Inativo';
2202
+ function renderProjectCards(projects, onlyRisk = false) {
2203
+ const box = $('projectCardsGrid');
2204
+ if (!box) return;
2205
+
2206
+ let filtered = projects;
2207
+ if (onlyRisk) {
2208
+ filtered = projects.filter(p => p.status === 'AT_RISK' || p.status === 'IDLE');
2209
+ }
2210
+
2211
+ if (filtered.length === 0) {
2212
+ box.innerHTML = '<div class="help">Nenhum projeto para exibir.</div>';
2213
+ return;
2214
+ }
2215
+
2216
+ box.innerHTML = filtered.map(p => {
2217
+ const statusColor = p.status === 'IDLE' ? '#666' : p.status === 'AT_RISK' ? '#ff9900' : '#4ade80';
2218
+ const statusText = p.status === 'IDLE' ? 'Inativo' : p.status === 'AT_RISK' ? 'Risco' : 'OK';
2194
2219
  return `
2195
- <div class="projectCard" style="border-left-color:${statusColor};">
2196
- <div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:10px;">
2197
- <div>
2198
- <div style="font-weight:700; font-size:14px;">${escapeHtml(p.name)}</div>
2199
- <div style="font-size:11px; color:var(--textSecondary); margin-top:2px;">${statusLabel}</div>
2200
- </div>
2201
- <span class="pill" style="background:${statusColor}22; border-color:${statusColor}55; color:${statusColor};">${progressPct}%</span>
2220
+ <div class="projectCard" style="border-left-color: ${statusColor}; cursor: pointer;" onclick="window.location.href='/dashboard?project=${encodeURIComponent(p.slug)}'">
2221
+ <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
2222
+ <div style="font-weight: 700; font-size: 13px;">${escapeHtml(p.name)}</div>
2223
+ <span class="pill" style="font-size: 9px; padding: 2px 6px;">${statusText}</span>
2202
2224
  </div>
2203
- <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; padding:8px; background:var(--bg); border-radius:0;">
2204
- <div style="text-align:center;">
2205
- <div style="font-size:10px; color:var(--textSecondary);">Concluídas</div>
2206
- <div style="font-size:14px; font-weight:700;">✓ ${p.completedTasks}/${p.totalTasks}</div>
2207
- </div>
2208
- <div style="text-align:center;">
2209
- <div style="font-size:10px; color:var(--textSecondary);">Blockers</div>
2210
- <div style="font-size:14px; font-weight:700;">🚧 ${p.blockersCount}</div>
2211
- </div>
2212
- </div>
2213
- <div style="font-size:10px; color:var(--textSecondary);">
2214
- <div>📈 Velocity: ${p.velocityThisWeek.toFixed(1)}/dia</div>
2215
- <div style="margin-top:2px;">⏱️ Última: ${escapeHtml(p.lastUpdateAgo)}</div>
2225
+ <div style="display: flex; gap: 12px; margin-bottom: 8px; font-size: 11px;">
2226
+ <div>✓ ${p.completedTasks}/${p.totalTasks}</div>
2227
+ <div>🚧 ${p.openBlockers}</div>
2216
2228
  </div>
2229
+ <div style="font-size: 10px; color: var(--textMuted);">Atualizado: ${p.lastUpdateAgo}</div>
2217
2230
  </div>
2218
2231
  `;
2219
2232
  }).join('');
2220
- grid.innerHTML = cards || '<div class="help">Nenhum projeto cadastrado.</div>';
2221
2233
  }
2222
2234
 
2223
- function renderStreamBreakdown(data) {
2224
- const el = $('streamBreakdown');
2225
- if (!el) return;
2235
+ function renderStreamBreakdown(breakdown, onlyRisk = false) {
2236
+ const box = $('streamBreakdown');
2237
+ if (!box) return;
2238
+
2239
+ if (breakdown.length === 0) {
2240
+ box.innerHTML = '<div class="help">Nenhum stream para exibir.</div>';
2241
+ return;
2242
+ }
2243
+
2226
2244
  let html = '';
2227
- for (const proj of data) {
2228
- html += `<div style="margin-bottom:16px;">
2229
- <div style="font-weight:700; margin-bottom:8px; color:var(--accent);">${escapeHtml(proj.projectName)}</div>`;
2230
- for (const stream of proj.streams) {
2231
- const pct = stream.totalTasks > 0 ? Math.round((stream.completedTasks / stream.totalTasks) * 100) : 0;
2232
- const blockersLabel = stream.blockersCount > 0 ? `🚧 ${stream.blockersCount} ${stream.blockersHighestSeverity || ''}` : '✓';
2233
- html += `<div class="streamItem">
2234
- <div style="display:flex; justify-content:space-between; align-items:center;">
2235
- <div>
2236
- <div style="font-size:12px;">${escapeHtml(stream.streamName)}</div>
2237
- <div style="font-size:10px; color:var(--textSecondary); margin-top:2px;">✓ ${stream.completedTasks}/${stream.totalTasks} (${pct}%) | ${blockersLabel}</div>
2245
+ for (const proj of breakdown) {
2246
+ let streams = proj.streams;
2247
+ if (onlyRisk) {
2248
+ streams = streams.filter(s => s.blockersCount > 0);
2249
+ }
2250
+
2251
+ if (streams.length === 0) continue;
2252
+
2253
+ html += `<div style="margin-bottom: 12px;">
2254
+ <div style="font-weight: 700; font-size: 12px; margin-bottom: 4px; color: var(--accent);">${escapeHtml(proj.projectName)}</div>`;
2255
+
2256
+ for (const s of streams) {
2257
+ const hasBlockers = s.blockersCount > 0;
2258
+ html += `
2259
+ <div class="streamItem" style="background: ${hasBlockers ? 'rgba(239,68,68,0.05)' : 'transparent'};">
2260
+ <div style="display: flex; justify-content: space-between; align-items: center;">
2261
+ <div style="font-size: 11px;">${escapeHtml(s.streamName)}</div>
2262
+ <div style="font-size: 10px; color: var(--textMuted);">✓ ${s.completedTasks}/${s.totalTasks}</div>
2238
2263
  </div>
2264
+ ${hasBlockers ? `<div style="font-size: 10px; margin-top: 4px; color: #ef4444;">🚧 ${s.blockersCount} bloqueio(s)</div>` : ''}
2239
2265
  </div>
2240
- </div>`;
2266
+ `;
2241
2267
  }
2242
- html += `</div>`;
2268
+
2269
+ html += '</div>';
2243
2270
  }
2244
- el.innerHTML = html || '<div class="help">Nenhuma frente cadastrada.</div>';
2271
+
2272
+ box.innerHTML = html || '<div class="help">Nenhum stream para exibir.</div>';
2245
2273
  }
2246
2274
 
2247
- function renderAlerts(alerts) {
2248
- const alertsZone = $('companionAlerts');
2249
- const alertsList = $('alertsList');
2250
- if (!alertsList) return;
2251
- if (!alerts || alerts.length === 0) {
2252
- alertsList.innerHTML = '<div class="help">✓ Nenhum alerta no momento!</div>';
2253
- if (alertsZone) alertsZone.style.display = 'none';
2275
+ function renderAlerts(alerts, onlyHighSeverity = false) {
2276
+ const box = $('alertsView');
2277
+ if (!box) return;
2278
+
2279
+ let filtered = alerts;
2280
+ if (onlyHighSeverity) {
2281
+ filtered = alerts.filter(a => a.severity === 'CRITICAL' || a.severity === 'HIGH');
2282
+ }
2283
+
2284
+ if (filtered.length === 0) {
2285
+ box.innerHTML = '<div class="help">Nenhum alerta para exibir.</div>';
2254
2286
  return;
2255
2287
  }
2256
- const items = alerts.map(a => {
2257
- const sevColor = a.severity === 'CRITICAL' ? '#ef4444' : a.severity === 'HIGH' ? '#ff9900' : '#eab308';
2258
- const sevLabel = a.severity === 'CRITICAL' ? '🔴 CRÍTICO' : a.severity === 'HIGH' ? '🟠 ALTO' : '🟡 MÉDIO';
2259
- return `<div class="alertItem" style="border-left-color:${sevColor}; background:${sevColor}11;">
2260
- <div style="display:flex; justify-content:space-between; gap:10px; align-items:start;">
2261
- <div style="min-width:0; flex:1;">
2262
- <div style="font-weight:700; color:${sevColor};">${sevLabel}</div>
2263
- <div style="font-size:12px; margin-top:4px;">${escapeHtml(a.message)}</div>
2264
- ${a.projectSlug ? `<div style="font-size:10px; color:var(--textSecondary); margin-top:4px;">Projeto: ${escapeHtml(a.projectSlug)}</div>` : ''}
2288
+
2289
+ box.innerHTML = filtered.slice(0, 20).map(a => {
2290
+ const severityColor = a.severity === 'CRITICAL' ? '#ef4444' : a.severity === 'HIGH' ? '#ff9900' : '#facc15';
2291
+ return `
2292
+ <div class="alertItem" style="border-left-color: ${severityColor};">
2293
+ <div style="display: flex; justify-content: space-between; align-items: center; font-size: 11px;">
2294
+ <span style="font-weight: 700; color: ${severityColor};">${a.severity}</span>
2295
+ <span style="color: var(--textMuted);">${a.type}</span>
2265
2296
  </div>
2297
+ <div style="font-size: 11px; margin-top: 4px;">${escapeHtml(a.message)}</div>
2266
2298
  </div>
2267
- </div>`;
2299
+ `;
2268
2300
  }).join('');
2269
- alertsList.innerHTML = items;
2270
- if (alertsZone) alertsZone.style.display = 'block';
2271
- }
2272
-
2273
- function filterCompanionView(filter) {
2274
- const tabs = document.querySelectorAll('.companionTabs .tab');
2275
- tabs.forEach(t => t.classList.remove('active'));
2276
- document.querySelector(`.companionTabs .tab[data-filter="${filter}"]`)?.classList.add('active');
2277
- const consolidated = $('companionConsolidated');
2278
- const projects = $('companionProjects');
2279
- const streams = $('streamBreakdown').parentElement.parentElement;
2280
- const alerts = $('companionAlerts');
2281
- if (filter === 'all') {
2282
- if (consolidated) consolidated.style.display = 'grid';
2283
- if (projects) projects.style.display = 'grid';
2284
- if (streams) streams.style.display = 'block';
2285
- if (alerts) alerts.style.display = 'none';
2286
- } else if (filter === 'alerts') {
2287
- if (consolidated) consolidated.style.display = 'none';
2288
- if (projects) projects.style.display = 'none';
2289
- if (streams) streams.style.display = 'none';
2290
- if (alerts) alerts.style.display = 'block';
2291
- } else if (filter === 'risk') {
2292
- if (consolidated) consolidated.style.display = 'grid';
2293
- if (projects) projects.style.display = 'grid';
2294
- if (streams) streams.style.display = 'block';
2295
- if (alerts) alerts.style.display = 'none';
2296
- }
2297
2301
  }
2298
2302
 
2303
+ window.filterCompanionView = function(filter) {
2304
+ document.querySelectorAll('.companionTabs .tab').forEach(tab => {
2305
+ tab.classList.remove('active');
2306
+ });
2307
+ document.querySelector(`[data-filter="${filter}"]`).classList.add('active');
2308
+ refreshCompanionDash();
2309
+ };
2310
+
2299
2311
  async function doHealth() {
2300
2312
  try {
2301
2313
  saveLocal();
@@ -2803,13 +2815,7 @@
2803
2815
  }
2804
2816
 
2805
2817
  if (isCompanionPage) {
2806
- await refreshHealthChecklist();
2807
- await refreshQualityScore();
2808
- await refreshExecutiveSummary();
2809
- await refreshAnomalies();
2810
- await refreshRiskRadar();
2811
- await refreshIncidents();
2812
- await refreshHeatmap();
2818
+ await refreshCompanionDash();
2813
2819
  return;
2814
2820
  }
2815
2821
 
@@ -2890,7 +2896,6 @@
2890
2896
  window.refreshAnomalies = refreshAnomalies;
2891
2897
  window.refreshRiskRadar = refreshRiskRadar;
2892
2898
  window.refreshCompanionDash = refreshCompanionDash;
2893
- window.filterCompanionView = filterCompanionView;
2894
2899
  window.copyOut = copyOut;
2895
2900
  window.copyPath = copyPath;
2896
2901
  window.openSelected = openSelected;
package/cli/web.js CHANGED
@@ -1797,40 +1797,55 @@ function buildCompanionHtml(safeDefault, appVersion) {
1797
1797
  <section class="reportsHeader">
1798
1798
  <div>
1799
1799
  <div class="reportsTitle">Scrum Master Companion</div>
1800
- <div class="reportsSubtitle">Sprint Health Dashboard Visão consolidada de seus projetos</div>
1800
+ <div class="reportsSubtitle">Acompanhamento de projetos e frentes em tempo real.</div>
1801
1801
  </div>
1802
1802
  <div class="reportsActions">
1803
- <button class="btn small" type="button" onclick="refreshCompanionDash()">↻ Atualizar</button>
1803
+ <button class="btn small" type="button" onclick="refreshCompanionDash()">Atualizar Dashboard</button>
1804
1804
  </div>
1805
1805
  </section>
1806
1806
 
1807
- <!-- TAB FILTERS -->
1807
+ <button class="btn small" type="button" onclick="window.location.href='/reports'" style="margin-bottom: 16px;">
1808
+ 📊 Ver / Gerar Relatórios →
1809
+ </button>
1810
+
1811
+ <!-- PROJECTS OVERVIEW -->
1808
1812
  <div class="companionTabs">
1809
- <button class="tab active" data-filter="all" onclick="filterCompanionView('all')">○ Todos</button>
1810
- <button class="tab" data-filter="alerts" onclick="filterCompanionView('alerts')">⚠️ Alertas</button>
1811
- <button class="tab" data-filter="risk" onclick="filterCompanionView('risk')">🔴 Risco Alto</button>
1813
+ <button class="tab active" data-filter="all" onclick="window.filterCompanionView('all')">○ Todos</button>
1814
+ <button class="tab" data-filter="alerts" onclick="window.filterCompanionView('alerts')">● Alertas</button>
1815
+ <button class="tab" data-filter="risk" onclick="window.filterCompanionView('risk')">⚠ Risco Alto</button>
1812
1816
  </div>
1813
1817
 
1814
1818
  <!-- CONSOLIDATED VIEW -->
1815
- <div id="companionConsolidated" class="consolidatedView"></div>
1819
+ <div id="consolidatedViewBox" style="display: none;">
1820
+ <section class="panel">
1821
+ <div class="panelHead"><b>Visão Consolidada</b></div>
1822
+ <div class="panelBody">
1823
+ <div class="consolidatedView" id="consolidatedView" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;"></div>
1824
+ </div>
1825
+ </section>
1826
+ </div>
1816
1827
 
1817
1828
  <!-- PROJECT CARDS GRID -->
1818
- <div id="companionProjects" class="projectCardsGrid"></div>
1829
+ <div id="projectCardsBox" style="display: none;">
1830
+ <div class="projectCardsGrid" id="projectCardsGrid"></div>
1831
+ </div>
1819
1832
 
1820
1833
  <!-- STREAM BREAKDOWN -->
1821
- <section class="panel" style="margin-top: 24px;">
1822
- <div class="panelHead"><b>Distribuição por Frente/Stream</b></div>
1823
- <div class="panelBody">
1824
- <div id="streamBreakdown" class="help">Carregando streams...</div>
1825
- </div>
1826
- </section>
1834
+ <div id="streamBreakdownBox" style="display: none;">
1835
+ <section class="panel">
1836
+ <div class="panelHead"><b>Detalhamento por Stream</b></div>
1837
+ <div class="panelBody">
1838
+ <div id="streamBreakdown"></div>
1839
+ </div>
1840
+ </section>
1841
+ </div>
1827
1842
 
1828
- <!-- ALERTS ZONE -->
1829
- <div id="companionAlerts" style="display: none; margin-top: 24px;">
1843
+ <!-- ALERTS VIEW -->
1844
+ <div id="alertsViewBox" style="display: none;">
1830
1845
  <section class="panel">
1831
- <div class="panelHead"><b>⚠️ Alertas Prioritários</b></div>
1846
+ <div class="panelHead"><b>Alertas Prioritários</b></div>
1832
1847
  <div class="panelBody">
1833
- <div id="alertsList"></div>
1848
+ <div id="alertsView"></div>
1834
1849
  </div>
1835
1850
  </section>
1836
1851
  </div>
@@ -1998,18 +2013,6 @@ function truncateText(text, maxLen) {
1998
2013
  return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
1999
2014
  }
2000
2015
 
2001
- function formatAgo(timestampMs) {
2002
- const ms = typeof timestampMs === 'number' ? timestampMs : (typeof timestampMs === 'string' ? Date.parse(timestampMs) : 0);
2003
- if (!Number.isFinite(ms)) return 'unknown';
2004
- const now = Date.now();
2005
- const diffMs = now - ms;
2006
- if (diffMs < 60000) return 'agora';
2007
- if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m`;
2008
- if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h`;
2009
- if (diffMs < 604800000) return `${Math.floor(diffMs / 86400000)}d`;
2010
- return `${Math.floor(diffMs / 604800000)}w`;
2011
- }
2012
-
2013
2016
  function getTimelineItems(workspaceDir) {
2014
2017
  const items = [];
2015
2018
  const dailyDir = path.join(workspaceDir, 'logs', 'daily');
@@ -2807,6 +2810,310 @@ async function cmdWeb({ port, dir, open, dev }) {
2807
2810
  return safeJson(res, 200, { ok: true, items });
2808
2811
  }
2809
2812
 
2813
+ // /api/companion/projects-summary: Consolidate project KPIs
2814
+ if (req.url === '/api/companion/projects-summary') {
2815
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2816
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2817
+ }
2818
+
2819
+ const now = Date.now();
2820
+ const projectMap = {}; // projectSlug -> { name, totalTasks, completedTasks, pendingTasks, blockers, streams, lastUpdateAgo, status }
2821
+
2822
+ // 1. Load tasks
2823
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2824
+ if (exists(taskFile)) {
2825
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2826
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2827
+
2828
+ for (const t of tasks) {
2829
+ const slug = String(t.projectSlug || '').trim();
2830
+ if (!slug) continue;
2831
+
2832
+ if (!projectMap[slug]) {
2833
+ projectMap[slug] = {
2834
+ slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
2835
+ openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
2836
+ streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
2837
+ };
2838
+ }
2839
+
2840
+ projectMap[slug].totalTasks++;
2841
+ if (t.status === 'COMPLETED') {
2842
+ projectMap[slug].completedTasks++;
2843
+ } else {
2844
+ projectMap[slug].pendingTasks++;
2845
+ }
2846
+
2847
+ const streamSlug = String(t.streamSlug || '').trim();
2848
+ if (streamSlug) {
2849
+ projectMap[slug].streams.add(streamSlug);
2850
+ }
2851
+
2852
+ if (t.createdAt) {
2853
+ const taskTime = Date.parse(t.createdAt);
2854
+ if (taskTime > projectMap[slug].lastUpdateMs) {
2855
+ projectMap[slug].lastUpdateMs = taskTime;
2856
+ }
2857
+ }
2858
+ }
2859
+ }
2860
+
2861
+ // 2. Load blockers
2862
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2863
+ if (exists(blockerFile)) {
2864
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2865
+ const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2866
+
2867
+ for (const b of blockers) {
2868
+ const slug = String(b.projectSlug || '').trim();
2869
+ if (!slug) continue;
2870
+
2871
+ if (!projectMap[slug]) {
2872
+ projectMap[slug] = {
2873
+ slug, name: slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
2874
+ openBlockers: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
2875
+ streams: new Set(), lastUpdateMs: now, status: 'ON_TRACK'
2876
+ };
2877
+ }
2878
+
2879
+ if (String(b.status || '').toUpperCase() === 'OPEN') {
2880
+ projectMap[slug].openBlockers++;
2881
+ const sev = String(b.severity || 'LOW').toUpperCase();
2882
+ if (projectMap[slug].blockersBySeverity[sev] !== undefined) {
2883
+ projectMap[slug].blockersBySeverity[sev]++;
2884
+ }
2885
+ }
2886
+
2887
+ if (b.createdAt) {
2888
+ const blockerTime = Date.parse(b.createdAt);
2889
+ if (blockerTime > projectMap[slug].lastUpdateMs) {
2890
+ projectMap[slug].lastUpdateMs = blockerTime;
2891
+ }
2892
+ }
2893
+ }
2894
+ }
2895
+
2896
+ // Helper: format time ago
2897
+ const formatAgo = (ms) => {
2898
+ const age = now - ms;
2899
+ const secs = Math.floor(age / 1000);
2900
+ if (secs < 60) return 'just now';
2901
+ const mins = Math.floor(secs / 60);
2902
+ if (mins < 60) return mins + 'm ago';
2903
+ const hours = Math.floor(mins / 60);
2904
+ if (hours < 24) return hours + 'h ago';
2905
+ const days = Math.floor(hours / 24);
2906
+ return days + 'd ago';
2907
+ };
2908
+
2909
+ // Calculate status and completion rate for each project
2910
+ const projects = [];
2911
+ for (const [slug, proj] of Object.entries(projectMap)) {
2912
+ const completionRate = proj.totalTasks > 0 ? Math.round((proj.completedTasks / proj.totalTasks) * 100) : 0;
2913
+ let status = 'ON_TRACK';
2914
+
2915
+ if (proj.blockersBySeverity.CRITICAL > 0) status = 'AT_RISK';
2916
+ if (proj.blockersBySeverity.HIGH >= 3) status = 'AT_RISK';
2917
+ if (proj.pendingTasks > 15) status = 'AT_RISK';
2918
+
2919
+ const ageDays = Math.floor((now - proj.lastUpdateMs) / (24 * 60 * 60 * 1000));
2920
+ if (ageDays > 14) status = 'IDLE';
2921
+
2922
+ projects.push({
2923
+ slug: proj.slug,
2924
+ name: proj.name,
2925
+ totalTasks: proj.totalTasks,
2926
+ completedTasks: proj.completedTasks,
2927
+ pendingTasks: proj.pendingTasks,
2928
+ completionRate,
2929
+ openBlockers: proj.openBlockers,
2930
+ blockersBySeverity: proj.blockersBySeverity,
2931
+ streams: Array.from(proj.streams),
2932
+ lastUpdateAgo: formatAgo(proj.lastUpdateMs),
2933
+ status
2934
+ });
2935
+ }
2936
+
2937
+ return safeJson(res, 200, { ok: true, projects });
2938
+ }
2939
+
2940
+ // /api/companion/streams-breakdown: Task/blocker breakdown by stream within each project
2941
+ if (req.url === '/api/companion/streams-breakdown') {
2942
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2943
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2944
+ }
2945
+
2946
+ const breakdownMap = {}; // projectSlug -> { projectName, streams: [...] }
2947
+
2948
+ // 1. Load tasks grouped by project & stream
2949
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2950
+ if (exists(taskFile)) {
2951
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2952
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2953
+
2954
+ for (const t of tasks) {
2955
+ const pSlug = String(t.projectSlug || '').trim();
2956
+ const sSlug = String(t.streamSlug || 'default').trim();
2957
+ if (!pSlug) continue;
2958
+
2959
+ if (!breakdownMap[pSlug]) {
2960
+ breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
2961
+ }
2962
+ if (!breakdownMap[pSlug].streams[sSlug]) {
2963
+ breakdownMap[pSlug].streams[sSlug] = {
2964
+ streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
2965
+ blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
2966
+ };
2967
+ }
2968
+
2969
+ breakdownMap[pSlug].streams[sSlug].totalTasks++;
2970
+ if (t.status === 'COMPLETED') {
2971
+ breakdownMap[pSlug].streams[sSlug].completedTasks++;
2972
+ } else {
2973
+ breakdownMap[pSlug].streams[sSlug].pendingTasks++;
2974
+ }
2975
+ }
2976
+ }
2977
+
2978
+ // 2. Load blockers grouped by project & stream
2979
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2980
+ if (exists(blockerFile)) {
2981
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2982
+ const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2983
+
2984
+ for (const b of blockers) {
2985
+ const pSlug = String(b.projectSlug || '').trim();
2986
+ const sSlug = String(b.streamSlug || 'default').trim();
2987
+ if (!pSlug) continue;
2988
+
2989
+ if (!breakdownMap[pSlug]) {
2990
+ breakdownMap[pSlug] = { projectName: pSlug, streams: {} };
2991
+ }
2992
+ if (!breakdownMap[pSlug].streams[sSlug]) {
2993
+ breakdownMap[pSlug].streams[sSlug] = {
2994
+ streamName: sSlug, totalTasks: 0, completedTasks: 0, pendingTasks: 0,
2995
+ blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
2996
+ };
2997
+ }
2998
+
2999
+ if (String(b.status || '').toUpperCase() === 'OPEN') {
3000
+ breakdownMap[pSlug].streams[sSlug].blockersCount++;
3001
+ const sev = String(b.severity || 'LOW').toUpperCase();
3002
+ if (breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev] !== undefined) {
3003
+ breakdownMap[pSlug].streams[sSlug].blockersBySeverity[sev]++;
3004
+ }
3005
+ }
3006
+ }
3007
+ }
3008
+
3009
+ // Convert to array format
3010
+ const breakdown = [];
3011
+ for (const [pSlug, pData] of Object.entries(breakdownMap)) {
3012
+ const streams = [];
3013
+ for (const [sSlug, sData] of Object.entries(pData.streams)) {
3014
+ streams.push({
3015
+ streamName: sData.streamName,
3016
+ totalTasks: sData.totalTasks,
3017
+ completedTasks: sData.completedTasks,
3018
+ pendingTasks: sData.pendingTasks,
3019
+ blockersCount: sData.blockersCount,
3020
+ blockersBySeverity: sData.blockersBySeverity
3021
+ });
3022
+ }
3023
+ breakdown.push({
3024
+ projectSlug: pSlug,
3025
+ projectName: pData.projectName,
3026
+ streams: streams.sort((a, b) => b.blockersCount - a.blockersCount)
3027
+ });
3028
+ }
3029
+
3030
+ return safeJson(res, 200, { ok: true, breakdown });
3031
+ }
3032
+
3033
+ // /api/companion/alerts: Prioritized alerts by severity
3034
+ if (req.url === '/api/companion/alerts') {
3035
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
3036
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
3037
+ }
3038
+
3039
+ const now = Date.now();
3040
+ const alerts = [];
3041
+
3042
+ // 1. Check for old blockers
3043
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
3044
+ if (exists(blockerFile)) {
3045
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
3046
+ const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
3047
+
3048
+ for (const b of blockers) {
3049
+ if (String(b.status || '').toUpperCase() === 'OPEN') {
3050
+ const createdTime = b.createdAt ? Date.parse(b.createdAt) : now;
3051
+ const ageDays = Math.floor((now - createdTime) / (24 * 60 * 60 * 1000));
3052
+
3053
+ let severity = 'MEDIUM';
3054
+ if (String(b.severity || '').toUpperCase() === 'CRITICAL') severity = 'CRITICAL';
3055
+ else if (ageDays > 7) severity = 'HIGH';
3056
+
3057
+ alerts.push({
3058
+ severity,
3059
+ type: 'old_blocker',
3060
+ projectSlug: String(b.projectSlug || '').trim(),
3061
+ streamName: String(b.streamSlug || '').trim(),
3062
+ message: `Bloqueio: ${b.title} (${ageDays} dias)`,
3063
+ age: ageDays,
3064
+ createdAt: b.createdAt
3065
+ });
3066
+ }
3067
+ }
3068
+ }
3069
+
3070
+ // 2. Check for stale projects
3071
+ const base = path.join(workspaceDir, 'data', 'Clients');
3072
+ if (exists(base)) {
3073
+ const stack = [base];
3074
+ while (stack.length) {
3075
+ const dirp = stack.pop();
3076
+ const entries = fs.readdirSync(dirp, { withFileTypes: true });
3077
+ for (const ent of entries) {
3078
+ const full = path.join(dirp, ent.name);
3079
+ if (ent.isDirectory()) stack.push(full);
3080
+ else if (ent.isFile() && ent.name === 'status.json') {
3081
+ const doc = readJsonOrNull(full) || {};
3082
+ const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
3083
+ if (doc.active !== false) {
3084
+ const lastUpdated = doc.lastUpdated ? Date.parse(doc.lastUpdated) : null;
3085
+ if (lastUpdated) {
3086
+ const ageDays = Math.floor((now - lastUpdated) / (24 * 60 * 60 * 1000));
3087
+ if (ageDays > 14) {
3088
+ alerts.push({
3089
+ severity: ageDays > 30 ? 'CRITICAL' : 'HIGH',
3090
+ type: 'stale_project',
3091
+ projectSlug: slug,
3092
+ streamName: '',
3093
+ message: `Projeto inativo por ${ageDays} dias`,
3094
+ age: ageDays,
3095
+ createdAt: doc.lastUpdated
3096
+ });
3097
+ }
3098
+ }
3099
+ }
3100
+ }
3101
+ }
3102
+ }
3103
+ }
3104
+
3105
+ // Sort by severity (CRITICAL > HIGH > MEDIUM) then by age
3106
+ const severityOrder = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
3107
+ alerts.sort((a, b) => {
3108
+ const sA = severityOrder[a.severity] || 0;
3109
+ const sB = severityOrder[b.severity] || 0;
3110
+ if (sA !== sB) return sB - sA;
3111
+ return (b.age || 0) - (a.age || 0);
3112
+ });
3113
+
3114
+ return safeJson(res, 200, { ok: true, alerts });
3115
+ }
3116
+
2810
3117
  if (req.url === '/api/incidents/resolve') {
2811
3118
  const title = payload.title;
2812
3119
  const index = Number.isInteger(payload.index) ? payload.index : null;
@@ -3666,202 +3973,6 @@ async function cmdWeb({ port, dir, open, dev }) {
3666
3973
  return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
3667
3974
  }
3668
3975
 
3669
- if (req.url === '/api/companion/projects-summary') {
3670
- if (!looksLikeFreyaWorkspace(workspaceDir)) {
3671
- return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', projects: [] });
3672
- }
3673
-
3674
- const now = Date.now();
3675
- const projects = {};
3676
-
3677
- // Aggregate tasks by projectSlug
3678
- const tasksRaw = dl.db.prepare('SELECT * FROM tasks WHERE status IN ("PENDING", "COMPLETED")').all();
3679
- const blockersRaw = dl.db.prepare('SELECT * FROM blockers WHERE status IN ("OPEN", "MITIGATING")').all();
3680
-
3681
- // Build project summary
3682
- for (const t of tasksRaw) {
3683
- const slug = String(t.project_slug || 'unassigned').trim();
3684
- if (!projects[slug]) {
3685
- projects[slug] = { slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, streams: new Set(), blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }, oldestBlockerDays: null, lastUpdateMs: null };
3686
- }
3687
- projects[slug].totalTasks++;
3688
- if (String(t.status).toUpperCase() === 'COMPLETED') projects[slug].completedTasks++;
3689
- else projects[slug].pendingTasks++;
3690
- if (t.completed_at) projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, Date.parse(t.completed_at));
3691
- if (t.created_at) projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, Date.parse(t.created_at));
3692
- const meta = t.metadata ? JSON.parse(String(t.metadata || '{}')) : {};
3693
- if (meta.streamSlug) projects[slug].streams.add(String(meta.streamSlug));
3694
- }
3695
-
3696
- // Add blockers to projects
3697
- for (const b of blockersRaw) {
3698
- const slug = String(b.project_slug || 'unassigned').trim();
3699
- if (!projects[slug]) {
3700
- projects[slug] = { slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, streams: new Set(), blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }, oldestBlockerDays: null, lastUpdateMs: null };
3701
- }
3702
- projects[slug].blockersCount++;
3703
- const sev = String(b.severity || 'LOW').toUpperCase();
3704
- if (projects[slug].blockersBySeverity[sev] !== undefined) projects[slug].blockersBySeverity[sev]++;
3705
- const createdMs = b.created_at ? Date.parse(b.created_at) : null;
3706
- if (createdMs) {
3707
- projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, createdMs);
3708
- const ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
3709
- if (projects[slug].oldestBlockerDays === null || ageDays > projects[slug].oldestBlockerDays) {
3710
- projects[slug].oldestBlockerDays = ageDays;
3711
- }
3712
- }
3713
- }
3714
-
3715
- // Convert to array and calculate status + velocity
3716
- const projectsArray = Object.values(projects).map(p => {
3717
- const completedThisWeek = dl.db.prepare('SELECT count(*) as count FROM tasks WHERE project_slug = ? AND status = "COMPLETED" AND completed_at > ?').get(p.slug, new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString());
3718
- const velocityThisWeek = completedThisWeek ? (completedThisWeek.count / 7).toFixed(1) : '0';
3719
- const lastUpdateAgo = p.lastUpdateMs ? formatAgo(p.lastUpdateMs) : 'nunca';
3720
- const hasOldBlockers = p.oldestBlockerDays && p.oldestBlockerDays > 7;
3721
- const hasHighSeverity = p.blockersBySeverity.CRITICAL > 0 || p.blockersBySeverity.HIGH > 0;
3722
- const isIdle = p.lastUpdateMs && (now - p.lastUpdateMs) > 7 * 24 * 60 * 60 * 1000;
3723
- const status = isIdle ? 'IDLE' : (hasHighSeverity || hasOldBlockers ? 'AT_RISK' : 'ON_TRACK');
3724
- return {
3725
- slug: p.slug,
3726
- name: p.slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
3727
- totalTasks: p.totalTasks,
3728
- completedTasks: p.completedTasks,
3729
- pendingTasks: p.pendingTasks,
3730
- blockersCount: p.blockersCount,
3731
- blockersBySeverity: p.blockersBySeverity,
3732
- oldestBlockerDays: p.oldestBlockerDays,
3733
- velocityThisWeek: parseFloat(velocityThisWeek),
3734
- lastUpdateAgo: lastUpdateAgo,
3735
- status: status,
3736
- streams: Array.from(p.streams)
3737
- };
3738
- });
3739
-
3740
- return safeJson(res, 200, { ok: true, projects: projectsArray });
3741
- }
3742
-
3743
- if (req.url === '/api/companion/streams-breakdown') {
3744
- if (!looksLikeFreyaWorkspace(workspaceDir)) {
3745
- return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', items: [] });
3746
- }
3747
-
3748
- const tasks = dl.db.prepare('SELECT * FROM tasks').all();
3749
- const blockers = dl.db.prepare('SELECT * FROM blockers').all();
3750
-
3751
- const breakdown = {};
3752
- for (const t of tasks) {
3753
- const projectSlug = String(t.project_slug || 'unassigned');
3754
- if (!breakdown[projectSlug]) breakdown[projectSlug] = {};
3755
- const meta = t.metadata ? JSON.parse(String(t.metadata || '{}')) : {};
3756
- const streamName = String(meta.streamSlug || 'Default');
3757
- if (!breakdown[projectSlug][streamName]) {
3758
- breakdown[projectSlug][streamName] = { totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersHighestSeverity: null };
3759
- }
3760
- breakdown[projectSlug][streamName].totalTasks++;
3761
- if (String(t.status).toUpperCase() === 'COMPLETED') breakdown[projectSlug][streamName].completedTasks++;
3762
- else breakdown[projectSlug][streamName].pendingTasks++;
3763
- }
3764
-
3765
- for (const b of blockers) {
3766
- if (String(b.status).toUpperCase() !== 'OPEN' && String(b.status).toUpperCase() !== 'MITIGATING') continue;
3767
- const projectSlug = String(b.project_slug || 'unassigned');
3768
- if (!breakdown[projectSlug]) breakdown[projectSlug] = {};
3769
- const meta = b.metadata ? JSON.parse(String(b.metadata || '{}')) : {};
3770
- const streamName = String(meta.streamSlug || 'Default');
3771
- if (!breakdown[projectSlug][streamName]) {
3772
- breakdown[projectSlug][streamName] = { totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersHighestSeverity: null };
3773
- }
3774
- breakdown[projectSlug][streamName].blockersCount++;
3775
- const sev = String(b.severity || 'LOW').toUpperCase();
3776
- const sevOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
3777
- const sevVal = sevOrder[sev] !== undefined ? sevOrder[sev] : 999;
3778
- const currentVal = breakdown[projectSlug][streamName].blockersHighestSeverity ? sevOrder[breakdown[projectSlug][streamName].blockersHighestSeverity] : 999;
3779
- if (sevVal < currentVal) breakdown[projectSlug][streamName].blockersHighestSeverity = sev;
3780
- }
3781
-
3782
- const items = Object.entries(breakdown).map(([projectSlug, streams]) => ({
3783
- projectSlug,
3784
- projectName: projectSlug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
3785
- streams: Object.entries(streams).map(([streamName, data]) => ({ streamName, ...data }))
3786
- }));
3787
-
3788
- return safeJson(res, 200, { ok: true, items });
3789
- }
3790
-
3791
- if (req.url === '/api/companion/alerts') {
3792
- if (!looksLikeFreyaWorkspace(workspaceDir)) {
3793
- return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', alerts: [] });
3794
- }
3795
-
3796
- const now = Date.now();
3797
- const alerts = [];
3798
-
3799
- const blockers = dl.db.prepare('SELECT * FROM blockers WHERE status IN ("OPEN", "MITIGATING")').all();
3800
- const tasks = dl.db.prepare('SELECT * FROM tasks WHERE status = "PENDING"').all();
3801
-
3802
- // Old blockers
3803
- for (const b of blockers) {
3804
- const createdMs = b.created_at ? Date.parse(b.created_at) : null;
3805
- if (createdMs) {
3806
- const ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
3807
- if (ageDays > 3) {
3808
- const sev = String(b.severity || '').toUpperCase();
3809
- alerts.push({
3810
- severity: sev === 'CRITICAL' ? 'CRITICAL' : sev === 'HIGH' ? 'HIGH' : 'MEDIUM',
3811
- type: 'old_blocker',
3812
- projectSlug: String(b.project_slug || ''),
3813
- streamName: 'blocker: ' + (b.title || ''),
3814
- message: `Blocker aberto há ${ageDays} dias: ${b.title || 'Sem titulo'}`,
3815
- ageDays
3816
- });
3817
- }
3818
- }
3819
- }
3820
-
3821
- // Stale projects (7+ days without update)
3822
- const projects = {};
3823
- for (const t of tasks) {
3824
- const slug = String(t.project_slug || '');
3825
- if (slug) {
3826
- const createdMs = t.created_at ? Date.parse(t.created_at) : null;
3827
- if (createdMs && (!projects[slug] || createdMs > projects[slug])) projects[slug] = createdMs;
3828
- }
3829
- }
3830
- for (const b of blockers) {
3831
- const slug = String(b.project_slug || '');
3832
- if (slug) {
3833
- const createdMs = b.created_at ? Date.parse(b.created_at) : null;
3834
- if (createdMs && (!projects[slug] || createdMs > projects[slug])) projects[slug] = createdMs;
3835
- }
3836
- }
3837
- for (const [slug, lastMs] of Object.entries(projects)) {
3838
- if (lastMs) {
3839
- const ageDays = Math.floor((now - lastMs) / (24 * 60 * 60 * 1000));
3840
- if (ageDays > 7) {
3841
- alerts.push({
3842
- severity: ageDays > 14 ? 'HIGH' : 'MEDIUM',
3843
- type: 'stale_project',
3844
- projectSlug: slug,
3845
- streamName: '',
3846
- message: `Projeto ${slug} sem updates há ${ageDays} dias`,
3847
- ageDays
3848
- });
3849
- }
3850
- }
3851
- }
3852
-
3853
- // Sort by severity + age
3854
- alerts.sort((a, b) => {
3855
- const sevOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2 };
3856
- const aVal = sevOrder[a.severity] || 99;
3857
- const bVal = sevOrder[b.severity] || 99;
3858
- if (aVal !== bVal) return aVal - bVal;
3859
- return (b.ageDays || 0) - (a.ageDays || 0);
3860
- });
3861
-
3862
- return safeJson(res, 200, { ok: true, alerts: alerts.slice(0, 10) });
3863
- }
3864
-
3865
3976
  // BUG-12: duplicate handlers for /api/incidents/resolve, /api/incidents,
3866
3977
  // and /api/tasks/heatmap were removed here — originals remain earlier in the file.
3867
3978
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "2.14.0",
3
+ "version": "2.14.1",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js && node scripts/validate-structure.js",
@@ -35,4 +35,4 @@
35
35
  "pdf-lib": "^1.17.1",
36
36
  "sql.js": "^1.12.0"
37
37
  }
38
- }
38
+ }