@cccarv82/freya 2.13.4 → 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
@@ -1501,6 +1501,100 @@ textarea:focus {
1501
1501
  animation: fadeIn 0.2s ease-out;
1502
1502
  }
1503
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
+
1504
1598
  * {
1505
1599
  border-radius: 0 !important;
1506
1600
  }
package/cli/web-ui.js CHANGED
@@ -2130,6 +2130,172 @@
2130
2130
  }
2131
2131
  }
2132
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
+
2133
2299
  async function doHealth() {
2134
2300
  try {
2135
2301
  saveLocal();
@@ -2723,6 +2889,8 @@
2723
2889
  window.refreshExecutiveSummary = refreshExecutiveSummary;
2724
2890
  window.refreshAnomalies = refreshAnomalies;
2725
2891
  window.refreshRiskRadar = refreshRiskRadar;
2892
+ window.refreshCompanionDash = refreshCompanionDash;
2893
+ window.filterCompanionView = filterCompanionView;
2726
2894
  window.copyOut = copyOut;
2727
2895
  window.copyPath = copyPath;
2728
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.4",
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",