@cccarv82/freya 2.13.3 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/web-ui.css CHANGED
@@ -371,6 +371,7 @@ body {
371
371
  align-items: center;
372
372
  padding: 16px 20px 10px;
373
373
  background: transparent;
374
+ flex-shrink: 0;
374
375
  }
375
376
 
376
377
  .brandLine {
@@ -592,6 +593,9 @@ body {
592
593
  gap: 18px;
593
594
  flex: 1;
594
595
  }
596
+ .centerBody > * {
597
+ flex-shrink: 0;
598
+ }
595
599
 
596
600
  .promptShell {
597
601
  display: flex;
@@ -1497,6 +1501,100 @@ textarea:focus {
1497
1501
  animation: fadeIn 0.2s ease-out;
1498
1502
  }
1499
1503
 
1504
+ /* ── Companion Dashboard Tabs ── */
1505
+ .companionTabs {
1506
+ display: flex;
1507
+ gap: 8px;
1508
+ margin: 16px 0;
1509
+ border-bottom: 1px solid var(--border);
1510
+ padding-bottom: 8px;
1511
+ }
1512
+
1513
+ .companionTabs .tab {
1514
+ padding: 8px 12px;
1515
+ background: transparent;
1516
+ border: none;
1517
+ cursor: pointer;
1518
+ color: var(--text);
1519
+ font-size: 13px;
1520
+ position: relative;
1521
+ transition: color 0.2s ease;
1522
+ }
1523
+
1524
+ .companionTabs .tab:hover {
1525
+ color: var(--accent);
1526
+ }
1527
+
1528
+ .companionTabs .tab.active {
1529
+ color: var(--accent);
1530
+ border-bottom: 2px solid var(--accent);
1531
+ padding-bottom: 6px;
1532
+ margin-bottom: -10px;
1533
+ }
1534
+
1535
+ /* ── Consolidated View (4 KPI Cards) ── */
1536
+ .consolidatedView {
1537
+ display: grid;
1538
+ grid-template-columns: repeat(4, 1fr);
1539
+ gap: 16px;
1540
+ margin-bottom: 24px;
1541
+ }
1542
+
1543
+ .kpiCard {
1544
+ background: var(--paper);
1545
+ padding: 16px;
1546
+ border: 1px solid var(--border);
1547
+ }
1548
+
1549
+ /* ── Project Cards Grid ── */
1550
+ .projectCardsGrid {
1551
+ display: grid;
1552
+ grid-template-columns: repeat(2, 1fr);
1553
+ gap: 16px;
1554
+ margin-bottom: 24px;
1555
+ }
1556
+
1557
+ .projectCard {
1558
+ background: var(--paper);
1559
+ padding: 16px;
1560
+ border-left: 3px solid #4ade80;
1561
+ cursor: pointer;
1562
+ transition: box-shadow 0.2s ease;
1563
+ }
1564
+
1565
+ .projectCard:hover {
1566
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1567
+ }
1568
+
1569
+ .projectCard.at_risk {
1570
+ border-left-color: #ff9900;
1571
+ }
1572
+
1573
+ .projectCard.idle {
1574
+ border-left-color: #666;
1575
+ }
1576
+
1577
+ /* ── Stream Item ── */
1578
+ .streamItem {
1579
+ padding: 8px;
1580
+ margin: 4px 0;
1581
+ border: 1px solid var(--border);
1582
+ cursor: pointer;
1583
+ transition: background 0.2s ease;
1584
+ }
1585
+
1586
+ .streamItem:hover {
1587
+ background: var(--bg);
1588
+ }
1589
+
1590
+ /* ── Alert Item ── */
1591
+ .alertItem {
1592
+ padding: 12px;
1593
+ margin: 8px 0;
1594
+ border-left: 3px solid #ef4444;
1595
+ background: rgba(239, 68, 68, 0.05);
1596
+ }
1597
+
1500
1598
  * {
1501
1599
  border-radius: 0 !important;
1502
1600
  }
package/cli/web-ui.js CHANGED
@@ -139,9 +139,10 @@
139
139
  }
140
140
 
141
141
  const li = line.match(/^[ \t]*[-*][ \t]+(.*)$/);
142
- if (li) {
142
+ const oli = !li ? line.match(/^[ \t]*\d+\.[ \t]+(.*)$/) : null;
143
+ if (li || oli) {
143
144
  if (!inList) { html += '<ul class="md-ul">'; inList = true; }
144
- const content = inlineFormat(li[1]);
145
+ const content = inlineFormat((li || oli)[1]);
145
146
  html += '<li>' + content + '</li>';
146
147
  continue;
147
148
  }
@@ -192,42 +193,37 @@
192
193
 
193
194
  // --- Strategy 2: regex fallback for truncated/malformed JSON ---
194
195
  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++;
202
201
  var t = m[1].slice(0, 140);
203
- lines.push(num + '. \u{1F4DD} **Registrar no log:** ' + t + (m[1].length > 140 ? '...' : ''));
202
+ lines.push('- \u{1F4DD} **Registrar no log:** ' + t + (m[1].length > 140 ? '...' : ''));
204
203
  }
205
204
 
206
205
  // Match create_task actions
207
206
  var taskRe = /"type"\s*:\s*"create_?task"\s*,\s*"description"\s*:\s*"([^"]{1,200})/gi;
208
207
  while ((m = taskRe.exec(text)) !== null) {
209
- num++;
210
208
  var desc = m[1].slice(0, 120);
211
209
  var priMatch = text.slice(m.index, m.index + 400).match(/"priority"\s*:\s*"(\w+)"/i);
212
210
  var pri = priMatch ? ' (prioridade: **' + priMatch[1].toUpperCase() + '**)' : '';
213
- lines.push(num + '. \u2705 **Criar tarefa:** ' + desc + pri);
211
+ lines.push('- \u2705 **Criar tarefa:** ' + desc + pri);
214
212
  }
215
213
 
216
214
  // Match create_blocker actions
217
215
  var blockerRe = /"type"\s*:\s*"create_?blocker"\s*,\s*"title"\s*:\s*"([^"]{1,200})/gi;
218
216
  while ((m = blockerRe.exec(text)) !== null) {
219
- num++;
220
217
  var title = m[1].slice(0, 120);
221
218
  var sevMatch = text.slice(m.index, m.index + 400).match(/"severity"\s*:\s*"(\w+)"/i);
222
219
  var sev = sevMatch ? ' (severidade: **' + sevMatch[1].toUpperCase() + '**)' : '';
223
- lines.push(num + '. \u{1F6A7} **Registrar blocker:** ' + title + sev);
220
+ lines.push('- \u{1F6A7} **Registrar blocker:** ' + title + sev);
224
221
  }
225
222
 
226
223
  // Match suggest_report actions
227
224
  var repRe = /"type"\s*:\s*"suggest_?report"\s*,\s*"name"\s*:\s*"([^"]+)"/gi;
228
225
  while ((m = repRe.exec(text)) !== null) {
229
- num++;
230
- lines.push(num + '. \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
226
+ lines.push('- \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
231
227
  }
232
228
 
233
229
  return lines.length > 0 ? lines.join('\n') : null;
@@ -240,33 +236,32 @@
240
236
  oraclequery: '\u{1F50D}'
241
237
  };
242
238
 
243
- var lines = actions.map(function(a, i) {
239
+ var lines = actions.map(function(a) {
244
240
  var type = String(a.type || '').trim().toLowerCase().replace(/_/g, '');
245
241
  var icon = icons[type] || '\u2022';
246
- var num = i + 1;
247
242
 
248
243
  if (type === 'appenddailylog') {
249
244
  var t = String(a.text || '').slice(0, 140);
250
- return num + '. ' + icon + ' **Registrar no log:** ' + t + (String(a.text || '').length > 140 ? '...' : '');
245
+ return '- ' + icon + ' **Registrar no log:** ' + t + (String(a.text || '').length > 140 ? '...' : '');
251
246
  }
252
247
  if (type === 'createtask') {
253
248
  var desc = String(a.description || '').slice(0, 120);
254
249
  var pri = a.priority ? ' (prioridade: **' + String(a.priority).toUpperCase() + '**)' : '';
255
250
  var cat = a.category ? ' [' + a.category + ']' : '';
256
- return num + '. ' + icon + ' **Criar tarefa:** ' + desc + pri + cat;
251
+ return '- ' + icon + ' **Criar tarefa:** ' + desc + pri + cat;
257
252
  }
258
253
  if (type === 'createblocker') {
259
254
  var title = String(a.title || a.description || '').slice(0, 120);
260
255
  var sev = a.severity ? ' (severidade: **' + String(a.severity).toUpperCase() + '**)' : '';
261
- return num + '. ' + icon + ' **Registrar blocker:** ' + title + sev;
256
+ return '- ' + icon + ' **Registrar blocker:** ' + title + sev;
262
257
  }
263
258
  if (type === 'suggestreport') {
264
- return num + '. ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
259
+ return '- ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
265
260
  }
266
261
  if (type === 'oraclequery') {
267
- return num + '. ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
262
+ return '- ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
268
263
  }
269
- return num + '. \u2022 **' + String(a.type || 'acao') + '**';
264
+ return '- \u2022 **' + String(a.type || 'acao') + '**';
270
265
  });
271
266
 
272
267
  return lines.join('\n');
@@ -2135,6 +2130,172 @@
2135
2130
  }
2136
2131
  }
2137
2132
 
2133
+ async function refreshCompanionDash() {
2134
+ try {
2135
+ setPill('run', 'carregando dashboard...');
2136
+ const [pRes, sRes, aRes] = await Promise.all([
2137
+ api('/api/companion/projects-summary', { dir: dirOrDefault() }),
2138
+ api('/api/companion/streams-breakdown', { dir: dirOrDefault() }),
2139
+ api('/api/companion/alerts', { dir: dirOrDefault() })
2140
+ ]);
2141
+ if (!pRes || !pRes.ok || !sRes || !sRes.ok || !aRes || !aRes.ok) {
2142
+ setPill('err', 'Falha ao carregar dashboard');
2143
+ return;
2144
+ }
2145
+ renderConsolidatedView(pRes.projects || []);
2146
+ renderProjectCards(pRes.projects || []);
2147
+ renderStreamBreakdown(sRes.items || []);
2148
+ renderAlerts(aRes.alerts || []);
2149
+ setPill('ok', 'dashboard pronto');
2150
+ } catch (e) {
2151
+ setPill('err', 'Erro ao carregar dashboard');
2152
+ console.error('refreshCompanionDash error:', e);
2153
+ }
2154
+ }
2155
+
2156
+ 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>
2172
+ <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>
2175
+ </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
+ `;
2185
+ }
2186
+
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';
2194
+ 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>
2202
+ </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>
2216
+ </div>
2217
+ </div>
2218
+ `;
2219
+ }).join('');
2220
+ grid.innerHTML = cards || '<div class="help">Nenhum projeto cadastrado.</div>';
2221
+ }
2222
+
2223
+ function renderStreamBreakdown(data) {
2224
+ const el = $('streamBreakdown');
2225
+ if (!el) return;
2226
+ 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>
2238
+ </div>
2239
+ </div>
2240
+ </div>`;
2241
+ }
2242
+ html += `</div>`;
2243
+ }
2244
+ el.innerHTML = html || '<div class="help">Nenhuma frente cadastrada.</div>';
2245
+ }
2246
+
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';
2254
+ return;
2255
+ }
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>` : ''}
2265
+ </div>
2266
+ </div>
2267
+ </div>`;
2268
+ }).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
+ }
2298
+
2138
2299
  async function doHealth() {
2139
2300
  try {
2140
2301
  saveLocal();
@@ -2728,6 +2889,8 @@
2728
2889
  window.refreshExecutiveSummary = refreshExecutiveSummary;
2729
2890
  window.refreshAnomalies = refreshAnomalies;
2730
2891
  window.refreshRiskRadar = refreshRiskRadar;
2892
+ window.refreshCompanionDash = refreshCompanionDash;
2893
+ window.filterCompanionView = filterCompanionView;
2731
2894
  window.copyOut = copyOut;
2732
2895
  window.copyPath = copyPath;
2733
2896
  window.openSelected = openSelected;
package/cli/web.js CHANGED
@@ -1797,97 +1797,42 @@ 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">Painel rápido para gerar relatórios e checar pendências.</div>
1800
+ <div class="reportsSubtitle">Sprint Health Dashboard Visão consolidada de seus projetos</div>
1801
1801
  </div>
1802
1802
  <div class="reportsActions">
1803
- <button class="btn small" type="button" onclick="refreshHealthChecklist()">Atualizar Painel</button>
1803
+ <button class="btn small" type="button" onclick="refreshCompanionDash()">↻ Atualizar</button>
1804
1804
  </div>
1805
1805
  </section>
1806
1806
 
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
- <!-- BENTO GRID LAYOUT -->
1812
- <div style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 24px; align-items: start;">
1813
-
1814
- <!-- Left Column / Block 1 -->
1815
- <div style="display: flex; flex-direction: column; gap: 16px;">
1816
- <section class="panel">
1817
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1818
- <b>Resumo Executivo</b>
1819
- <button class="btn small" type="button" onclick="refreshExecutiveSummary()">↻</button>
1820
- </div>
1821
- <div class="panelBody">
1822
- <div id="executiveSummary" class="help"></div>
1823
- </div>
1824
- </section>
1825
-
1826
- <section class="panel">
1827
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1828
- <b>Radar de Risco</b>
1829
- <button class="btn small" type="button" onclick="refreshRiskRadar()">↻</button>
1830
- </div>
1831
- <div class="panelBody">
1832
- <div id="riskRadarBox"></div>
1833
- </div>
1834
- </section>
1835
-
1836
- <section class="panel">
1837
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1838
- <b>Task Heatmap</b>
1839
- </div>
1840
- <div class="panelBody">
1841
- <div id="heatmapGrid"></div>
1842
- </div>
1843
- </section>
1844
- </div>
1845
-
1846
- <!-- Right Column / Block 2 -->
1847
- <div style="display: flex; flex-direction: column; gap: 16px;">
1848
- <section class="panel">
1849
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1850
- <b style="color: var(--accent);">Saúde & Checklists</b>
1851
- </div>
1852
- <div class="panelBody" style="padding: 0;">
1853
- <section class="reportsGrid" id="healthChecklist" style="gap: 0; border: none; box-shadow: none;"></section>
1854
- </div>
1855
- </section>
1807
+ <!-- TAB FILTERS -->
1808
+ <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>
1812
+ </div>
1856
1813
 
1857
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
1858
- <section class="panel">
1859
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1860
- <b>Qualidade de Log</b>
1861
- <button class="btn small" type="button" onclick="refreshQualityScore()">↻</button>
1862
- </div>
1863
- <div class="panelBody">
1864
- <div id="qualityScoreCard"></div>
1865
- </div>
1866
- </section>
1814
+ <!-- CONSOLIDATED VIEW -->
1815
+ <div id="companionConsolidated" class="consolidatedView"></div>
1867
1816
 
1868
- <section class="panel">
1869
- <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1870
- <b>Anomalias</b>
1871
- <button class="btn small" type="button" onclick="refreshAnomalies()">↻</button>
1872
- </div>
1873
- <div class="panelBody">
1874
- <div id="anomaliesBox"></div>
1875
- </div>
1876
- </section>
1877
- </div>
1878
- </div>
1879
-
1880
- <!-- Full Width Span -->
1881
- <div style="grid-column: 1 / -1; display: flex; flex-direction: column; gap: 16px;">
1882
- <section class="panel">
1883
- <div class="panelHead"><b>Incident Radar</b></div>
1884
- <div class="panelBody">
1885
- <div id="incidentsBox" class="log md" style="font-family: var(--sans);"></div>
1886
- </div>
1887
- </section>
1817
+ <!-- PROJECT CARDS GRID -->
1818
+ <div id="companionProjects" class="projectCardsGrid"></div>
1888
1819
 
1820
+ <!-- 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>
1889
1825
  </div>
1826
+ </section>
1890
1827
 
1828
+ <!-- ALERTS ZONE -->
1829
+ <div id="companionAlerts" style="display: none; margin-top: 24px;">
1830
+ <section class="panel">
1831
+ <div class="panelHead"><b>⚠️ Alertas Prioritários</b></div>
1832
+ <div class="panelBody">
1833
+ <div id="alertsList"></div>
1834
+ </div>
1835
+ </section>
1891
1836
  </div>
1892
1837
  </div>
1893
1838
  </main>
@@ -2053,6 +1998,18 @@ function truncateText(text, maxLen) {
2053
1998
  return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
2054
1999
  }
2055
2000
 
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
+
2056
2013
  function getTimelineItems(workspaceDir) {
2057
2014
  const items = [];
2058
2015
  const dailyDir = path.join(workspaceDir, 'logs', 'daily');
@@ -3709,6 +3666,202 @@ async function cmdWeb({ port, dir, open, dev }) {
3709
3666
  return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
3710
3667
  }
3711
3668
 
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
+
3712
3865
  // BUG-12: duplicate handlers for /api/incidents/resolve, /api/incidents,
3713
3866
  // and /api/tasks/heatmap were removed here — originals remain earlier in the file.
3714
3867
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "2.13.3",
3
+ "version": "2.14.0",
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",