@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 +38 -23
- package/cli/web-ui.js +154 -149
- package/cli/web.js +337 -226
- package/package.json +2 -2
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
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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(--
|
|
1528
|
+
color: var(--text);
|
|
1526
1529
|
}
|
|
1527
1530
|
|
|
1528
1531
|
.companionTabs .tab.active {
|
|
1529
1532
|
color: var(--accent);
|
|
1530
|
-
border-bottom:
|
|
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
|
|
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:
|
|
1574
|
+
transition: background 0.2s, border-color 0.2s;
|
|
1563
1575
|
}
|
|
1564
1576
|
|
|
1565
1577
|
.projectCard:hover {
|
|
1566
|
-
|
|
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:
|
|
1591
|
+
padding: 12px;
|
|
1580
1592
|
margin: 4px 0;
|
|
1581
1593
|
border: 1px solid var(--border);
|
|
1582
|
-
|
|
1583
|
-
transition: background 0.2s
|
|
1594
|
+
background: transparent;
|
|
1595
|
+
transition: background 0.2s;
|
|
1596
|
+
font-size: 11px;
|
|
1584
1597
|
}
|
|
1585
1598
|
|
|
1586
1599
|
.streamItem:hover {
|
|
1587
|
-
background:
|
|
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
|
-
|
|
143
|
-
if (li || oli) {
|
|
142
|
+
if (li) {
|
|
144
143
|
if (!inList) { html += '<ul class="md-ul">'; inList = true; }
|
|
145
|
-
const content = inlineFormat(
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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 '
|
|
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 '
|
|
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 '
|
|
261
|
+
return num + '. ' + icon + ' **Registrar blocker:** ' + title + sev;
|
|
257
262
|
}
|
|
258
263
|
if (type === 'suggestreport') {
|
|
259
|
-
return '
|
|
264
|
+
return num + '. ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
|
|
260
265
|
}
|
|
261
266
|
if (type === 'oraclequery') {
|
|
262
|
-
return '
|
|
267
|
+
return num + '. ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
|
|
263
268
|
}
|
|
264
|
-
return '
|
|
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
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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
|
-
|
|
2146
|
-
|
|
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', '
|
|
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
|
|
2158
|
-
if (!
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
const
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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:
|
|
2174
|
-
<div style="font-size:
|
|
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
|
-
|
|
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
|
|
2189
|
-
if (!
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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
|
|
2196
|
-
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:
|
|
2197
|
-
<div>
|
|
2198
|
-
|
|
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:
|
|
2204
|
-
<div
|
|
2205
|
-
|
|
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(
|
|
2224
|
-
const
|
|
2225
|
-
if (!
|
|
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
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
-
|
|
2266
|
+
`;
|
|
2241
2267
|
}
|
|
2242
|
-
|
|
2268
|
+
|
|
2269
|
+
html += '</div>';
|
|
2243
2270
|
}
|
|
2244
|
-
|
|
2271
|
+
|
|
2272
|
+
box.innerHTML = html || '<div class="help">Nenhum stream para exibir.</div>';
|
|
2245
2273
|
}
|
|
2246
2274
|
|
|
2247
|
-
function renderAlerts(alerts) {
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
const
|
|
2259
|
-
return
|
|
2260
|
-
<div style="
|
|
2261
|
-
<div style="
|
|
2262
|
-
<
|
|
2263
|
-
<
|
|
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
|
-
|
|
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
|
|
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">
|
|
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()"
|
|
1803
|
+
<button class="btn small" type="button" onclick="refreshCompanionDash()">Atualizar Dashboard</button>
|
|
1804
1804
|
</div>
|
|
1805
1805
|
</section>
|
|
1806
1806
|
|
|
1807
|
-
|
|
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')"
|
|
1811
|
-
<button class="tab" data-filter="risk" onclick="filterCompanionView('risk')"
|
|
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="
|
|
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="
|
|
1829
|
+
<div id="projectCardsBox" style="display: none;">
|
|
1830
|
+
<div class="projectCardsGrid" id="projectCardsGrid"></div>
|
|
1831
|
+
</div>
|
|
1819
1832
|
|
|
1820
1833
|
<!-- STREAM BREAKDOWN -->
|
|
1821
|
-
<
|
|
1822
|
-
<
|
|
1823
|
-
|
|
1824
|
-
<div
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
|
1829
|
-
<div id="
|
|
1843
|
+
<!-- ALERTS VIEW -->
|
|
1844
|
+
<div id="alertsViewBox" style="display: none;">
|
|
1830
1845
|
<section class="panel">
|
|
1831
|
-
<div class="panelHead"><b
|
|
1846
|
+
<div class="panelHead"><b>Alertas Prioritários</b></div>
|
|
1832
1847
|
<div class="panelBody">
|
|
1833
|
-
<div id="
|
|
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.
|
|
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
|
+
}
|