@cccarv82/freya 2.3.6 → 2.3.8
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.js +239 -3
- package/cli/web.js +435 -39
- package/package.json +1 -1
package/cli/web-ui.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
autoApply: true,
|
|
18
18
|
autoRunReports: false,
|
|
19
19
|
prettyPublish: true,
|
|
20
|
+
timelineProject: '',
|
|
21
|
+
timelineTag: '',
|
|
20
22
|
chatSessionId: null,
|
|
21
23
|
chatLoaded: false
|
|
22
24
|
};
|
|
@@ -705,8 +707,61 @@
|
|
|
705
707
|
const filter = String(($('timelineFilter') && $('timelineFilter').value) || '').toLowerCase();
|
|
706
708
|
const items = Array.isArray(state.timeline) ? state.timeline : [];
|
|
707
709
|
const kind = state.timelineKind || 'all';
|
|
710
|
+
const projectSelect = $('timelineProject');
|
|
711
|
+
const tagSelect = $('timelineTag');
|
|
712
|
+
const getItemSlug = (it) => {
|
|
713
|
+
if (!it) return '';
|
|
714
|
+
if (it.slug) return String(it.slug);
|
|
715
|
+
if (it.kind === 'task' && it.content) return String(it.content);
|
|
716
|
+
return '';
|
|
717
|
+
};
|
|
718
|
+
const collectTimelineOptions = (list) => {
|
|
719
|
+
const slugs = new Set();
|
|
720
|
+
const tags = new Set();
|
|
721
|
+
for (const it of list) {
|
|
722
|
+
const slug = getItemSlug(it);
|
|
723
|
+
if (slug) slugs.add(slug);
|
|
724
|
+
const itTags = Array.isArray(it.tags) ? it.tags : [];
|
|
725
|
+
for (const tag of itTags) {
|
|
726
|
+
const cleaned = String(tag || '').trim();
|
|
727
|
+
if (cleaned) tags.add(cleaned);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
slugs: Array.from(slugs).sort((a, b) => a.localeCompare(b)),
|
|
732
|
+
tags: Array.from(tags).sort((a, b) => a.localeCompare(b))
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
const syncSelect = (selectEl, options, selected, placeholder) => {
|
|
736
|
+
if (!selectEl) return selected;
|
|
737
|
+
const nextSelected = options.includes(selected) ? selected : '';
|
|
738
|
+
selectEl.innerHTML = '';
|
|
739
|
+
const allOpt = document.createElement('option');
|
|
740
|
+
allOpt.value = '';
|
|
741
|
+
allOpt.textContent = placeholder;
|
|
742
|
+
selectEl.appendChild(allOpt);
|
|
743
|
+
for (const opt of options) {
|
|
744
|
+
const optionEl = document.createElement('option');
|
|
745
|
+
optionEl.value = opt;
|
|
746
|
+
optionEl.textContent = opt;
|
|
747
|
+
selectEl.appendChild(optionEl);
|
|
748
|
+
}
|
|
749
|
+
selectEl.value = nextSelected;
|
|
750
|
+
return nextSelected;
|
|
751
|
+
};
|
|
752
|
+
const options = collectTimelineOptions(items);
|
|
753
|
+
const selectedProject = syncSelect(projectSelect, options.slugs, state.timelineProject || '', 'Todos projetos');
|
|
754
|
+
const selectedTag = syncSelect(tagSelect, options.tags, state.timelineTag || '', 'Todas tags');
|
|
755
|
+
state.timelineProject = selectedProject;
|
|
756
|
+
state.timelineTag = selectedTag;
|
|
708
757
|
const filtered = items.filter((i) => {
|
|
709
|
-
const
|
|
758
|
+
const slug = getItemSlug(i);
|
|
759
|
+
if (state.timelineProject && slug !== state.timelineProject) return false;
|
|
760
|
+
if (state.timelineTag) {
|
|
761
|
+
const itTags = Array.isArray(i.tags) ? i.tags.map((t) => String(t)) : [];
|
|
762
|
+
if (!itTags.includes(state.timelineTag)) return false;
|
|
763
|
+
}
|
|
764
|
+
const hay = [i.kind, i.title, i.content, i.slug, (i.tags || []).join(' ')].join(' ').toLowerCase();
|
|
710
765
|
return !filter || hay.includes(filter);
|
|
711
766
|
});
|
|
712
767
|
el.innerHTML = '';
|
|
@@ -747,6 +802,16 @@
|
|
|
747
802
|
renderTimeline();
|
|
748
803
|
}
|
|
749
804
|
|
|
805
|
+
function setTimelineProject(project) {
|
|
806
|
+
state.timelineProject = String(project || '');
|
|
807
|
+
renderTimeline();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function setTimelineTag(tag) {
|
|
811
|
+
state.timelineTag = String(tag || '');
|
|
812
|
+
renderTimeline();
|
|
813
|
+
}
|
|
814
|
+
|
|
750
815
|
async function refreshTimeline() {
|
|
751
816
|
try {
|
|
752
817
|
const r = await api('/api/timeline', { dir: dirOrDefault() });
|
|
@@ -779,15 +844,54 @@
|
|
|
779
844
|
if (current) cards.push(current);
|
|
780
845
|
el.innerHTML = '';
|
|
781
846
|
if (!cards.length) { el.innerHTML = renderMarkdown(md); return; }
|
|
782
|
-
for (
|
|
847
|
+
for (let idx = 0; idx < cards.length; idx++) {
|
|
848
|
+
const c = cards[idx];
|
|
783
849
|
const card = document.createElement('div');
|
|
784
850
|
card.className = 'reportCard';
|
|
785
851
|
const dateLine = c.body.find((b)=> b.toLowerCase().includes('data'));
|
|
786
852
|
const impactLine = c.body.find((b)=> b.toLowerCase().includes('descricao') || b.toLowerCase().includes('impacto'));
|
|
853
|
+
const statusLine = c.body.find((b)=> /^status\s*:/i.test(b));
|
|
854
|
+
const statusRaw = statusLine ? statusLine.split(':').slice(1).join(':').trim().toLowerCase() : '';
|
|
855
|
+
let statusKey = '';
|
|
856
|
+
if (['open', 'aberto', 'aberta'].includes(statusRaw)) statusKey = 'open';
|
|
857
|
+
else if (['mitigating', 'mitigando', 'mitigacao', 'mitigação'].includes(statusRaw)) statusKey = 'mitigating';
|
|
858
|
+
else if (['resolved', 'resolvido', 'resolvida', 'closed', 'fechado', 'fechada'].includes(statusRaw)) statusKey = 'resolved';
|
|
859
|
+
|
|
787
860
|
card.innerHTML = '<div class="reportTitle">' + escapeHtml(c.title) + '</div>'
|
|
788
861
|
+ (dateLine ? ('<div class="reportMeta">' + escapeHtml(dateLine) + '</div>') : '')
|
|
789
862
|
+ (impactLine ? ('<div class="help" style="margin-top:4px">' + escapeHtml(impactLine) + '</div>') : '')
|
|
790
|
-
+ c.body.filter((b)=> b!==dateLine && b!==impactLine).map((b) => '<div class="help" style="margin-top:4px">' + escapeHtml(b) + '</div>').join('');
|
|
863
|
+
+ c.body.filter((b)=> b!==dateLine && b!==impactLine && b!==statusLine).map((b) => '<div class="help" style="margin-top:4px">' + escapeHtml(b) + '</div>').join('');
|
|
864
|
+
|
|
865
|
+
if (statusKey) {
|
|
866
|
+
const actions = document.createElement('div');
|
|
867
|
+
actions.className = 'reportActions';
|
|
868
|
+
actions.style.display = 'flex';
|
|
869
|
+
actions.style.gap = '8px';
|
|
870
|
+
actions.style.marginTop = '8px';
|
|
871
|
+
actions.style.flexWrap = 'wrap';
|
|
872
|
+
|
|
873
|
+
const label = statusKey === 'open' ? 'aberto' : (statusKey === 'mitigating' ? 'mitigando' : 'resolvido');
|
|
874
|
+
const pillClass = statusKey === 'resolved' ? 'ok' : (statusKey === 'mitigating' ? 'info' : 'warn');
|
|
875
|
+
const pill = document.createElement('span');
|
|
876
|
+
pill.className = 'pill ' + pillClass;
|
|
877
|
+
pill.textContent = label;
|
|
878
|
+
actions.appendChild(pill);
|
|
879
|
+
|
|
880
|
+
if (statusKey !== 'resolved') {
|
|
881
|
+
const btn = document.createElement('button');
|
|
882
|
+
btn.className = 'btn small';
|
|
883
|
+
btn.type = 'button';
|
|
884
|
+
btn.textContent = 'Marcar resolvido';
|
|
885
|
+
btn.onclick = async () => {
|
|
886
|
+
await api('/api/incidents/resolve', { dir: dirOrDefault(), title: c.title, index: idx });
|
|
887
|
+
await refreshIncidents();
|
|
888
|
+
};
|
|
889
|
+
actions.appendChild(btn);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
card.appendChild(actions);
|
|
893
|
+
}
|
|
894
|
+
|
|
791
895
|
el.appendChild(card);
|
|
792
896
|
}
|
|
793
897
|
}
|
|
@@ -814,10 +918,20 @@
|
|
|
814
918
|
for (const it of items) {
|
|
815
919
|
const row = document.createElement('div');
|
|
816
920
|
row.className = 'rep';
|
|
921
|
+
const priority = String(it.priority || '').toLowerCase();
|
|
922
|
+
const pill = priority ? ('<span class="pill ' + (priority === 'high' ? 'warn' : (priority === 'medium' ? 'info' : '')) + '">' + escapeHtml(priority) + '</span>') : '';
|
|
923
|
+
const action = it.linkRel ? ('<button class="btn small" type="button" data-link="' + escapeHtml(it.linkRel) + '">Abrir status</button>') : '';
|
|
817
924
|
row.innerHTML = '<div style="display:flex; justify-content:space-between; gap:10px; align-items:center">'
|
|
818
925
|
+ '<div style="min-width:0"><div style="font-weight:800">' + escapeHtml(it.slug || 'unassigned') + '</div>'
|
|
819
926
|
+ '<div class="help" style="margin-top:4px">Total: ' + escapeHtml(String(it.total)) + ' · Pendentes: ' + escapeHtml(String(it.pending)) + ' · Concluidas: ' + escapeHtml(String(it.completed)) + '</div></div>'
|
|
927
|
+
+ '<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">' + pill + action + '</div>'
|
|
820
928
|
+ '</div>';
|
|
929
|
+
const btn = row.querySelector('button[data-link]');
|
|
930
|
+
if (btn) {
|
|
931
|
+
btn.onclick = async () => {
|
|
932
|
+
await api('/api/reports/open', { dir: dirOrDefault(), relPath: btn.getAttribute('data-link') });
|
|
933
|
+
};
|
|
934
|
+
}
|
|
821
935
|
el.appendChild(row);
|
|
822
936
|
}
|
|
823
937
|
if (!items.length) {
|
|
@@ -1154,6 +1268,120 @@
|
|
|
1154
1268
|
}
|
|
1155
1269
|
}
|
|
1156
1270
|
|
|
1271
|
+
async function refreshQualityScore() {
|
|
1272
|
+
const el = $('qualityScoreCard');
|
|
1273
|
+
if (el) el.innerHTML = '<div class="help">Carregando score...</div>';
|
|
1274
|
+
try {
|
|
1275
|
+
const r = await api('/api/quality/score', { dir: dirOrDefault() });
|
|
1276
|
+
if (!el) return;
|
|
1277
|
+
if (r && r.needsInit) {
|
|
1278
|
+
el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const score = (r && typeof r.score === 'number') ? r.score : null;
|
|
1282
|
+
const breakdown = (r && r.breakdown) ? r.breakdown : {};
|
|
1283
|
+
const threshold = 90;
|
|
1284
|
+
const status = (score !== null && score >= threshold) ? 'ok' : 'warn';
|
|
1285
|
+
|
|
1286
|
+
const line = (label, data, keyLabel) => {
|
|
1287
|
+
if (!data) return '';
|
|
1288
|
+
const pct = (typeof data.pct === 'number') ? `${data.pct}%` : 'n/a';
|
|
1289
|
+
const detail = keyLabel ? `${data[keyLabel] || 0}/${data.total || 0}` : `${data.total || 0}`;
|
|
1290
|
+
return `<div class=\"help\" style=\"margin-top:4px\"><b>${escapeHtml(label)}:</b> ${escapeHtml(pct)} (${escapeHtml(detail)})</div>`;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
const html = `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1294
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${score === null ? 'Sem score' : `Score: ${score}%`}</div>`
|
|
1295
|
+
+ `${line('Tasks com projectSlug', breakdown.tasks, 'withProjectSlug')}`
|
|
1296
|
+
+ `${line('Status com history', breakdown.status, 'withHistory')}`
|
|
1297
|
+
+ `${line('Blockers com projectSlug', breakdown.blockers, 'withProjectSlug')}`
|
|
1298
|
+
+ `</div>`
|
|
1299
|
+
+ `<div class=\"pill ${status}\">${status}</div>`
|
|
1300
|
+
+ `</div>`;
|
|
1301
|
+
el.innerHTML = html;
|
|
1302
|
+
} catch {
|
|
1303
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar score.</div>';
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function refreshRiskSummary() {
|
|
1308
|
+
const el = $('riskSummary');
|
|
1309
|
+
if (el) el.innerHTML = '<div class="help">Carregando riscos...</div>';
|
|
1310
|
+
try {
|
|
1311
|
+
const r = await api('/api/risk/summary', { dir: dirOrDefault() });
|
|
1312
|
+
if (!el) return;
|
|
1313
|
+
if (r && r.needsInit) {
|
|
1314
|
+
el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const items = Array.isArray(r.items) ? r.items : [];
|
|
1318
|
+
if (!items.length) {
|
|
1319
|
+
el.innerHTML = '<div class="help">Sem riscos relevantes.</div>';
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const rows = items.map((it) => {
|
|
1323
|
+
const age = (it.oldestBlockerDays != null) ? `${it.oldestBlockerDays}d` : 'n/a';
|
|
1324
|
+
return `<div class=\"rep\">`
|
|
1325
|
+
+ `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1326
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(it.slug || '')}</div>`
|
|
1327
|
+
+ `<div class=\"help\" style=\"margin-top:4px\">Pendentes: ${escapeHtml(String(it.pendingTasks || 0))} · Blockers 7d+: ${escapeHtml(String(it.oldBlockers || 0))} · Mais antigo: ${escapeHtml(age)}</div>`
|
|
1328
|
+
+ `</div>`
|
|
1329
|
+
+ `<div class=\"pill warn\">risco</div>`
|
|
1330
|
+
+ `</div>`
|
|
1331
|
+
+ `</div>`;
|
|
1332
|
+
}).join('');
|
|
1333
|
+
el.innerHTML = rows;
|
|
1334
|
+
} catch {
|
|
1335
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar riscos.</div>';
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
async function refreshExecutiveSummary() {
|
|
1340
|
+
const el = $('executiveSummary');
|
|
1341
|
+
if (el) el.textContent = 'Carregando resumo...';
|
|
1342
|
+
try {
|
|
1343
|
+
const r = await api('/api/summary/executive', { dir: dirOrDefault() });
|
|
1344
|
+
if (!el) return;
|
|
1345
|
+
el.textContent = r.summary || 'Sem resumo disponível.';
|
|
1346
|
+
} catch {
|
|
1347
|
+
if (el) el.textContent = 'Falha ao carregar resumo.';
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function refreshAnomalies() {
|
|
1352
|
+
const el = $('anomaliesBox');
|
|
1353
|
+
if (el) el.innerHTML = '<div class="help">Carregando anomalias...</div>';
|
|
1354
|
+
try {
|
|
1355
|
+
const r = await api('/api/anomalies', { dir: dirOrDefault() });
|
|
1356
|
+
if (!el) return;
|
|
1357
|
+
const anomalies = (r && r.anomalies) ? r.anomalies : {};
|
|
1358
|
+
const tasksMissing = anomalies.tasksMissingProject || { count: 0, samples: [] };
|
|
1359
|
+
const statusMissing = anomalies.statusMissingHistory || { count: 0, samples: [] };
|
|
1360
|
+
|
|
1361
|
+
const rows = [];
|
|
1362
|
+
const pushRow = (label, data) => {
|
|
1363
|
+
const status = data.count > 0 ? 'warn' : 'ok';
|
|
1364
|
+
const samples = (data.samples || []).slice(0, 5).map((s) => `<div class=\"help\" style=\"margin-top:4px\">${escapeHtml(s)}</div>`).join('');
|
|
1365
|
+
rows.push(
|
|
1366
|
+
`<div class=\"rep\">`
|
|
1367
|
+
+ `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1368
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(label)}</div>`
|
|
1369
|
+
+ `<div class=\"help\" style=\"margin-top:4px\">${data.count} ocorrência(s)</div>`
|
|
1370
|
+
+ `${samples}</div>`
|
|
1371
|
+
+ `<div class=\"pill ${status}\">${status}</div>`
|
|
1372
|
+
+ `</div>`
|
|
1373
|
+
+ `</div>`
|
|
1374
|
+
);
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
pushRow('Tarefas sem projectSlug', tasksMissing);
|
|
1378
|
+
pushRow('Status sem history', statusMissing);
|
|
1379
|
+
el.innerHTML = rows.join('') || '<div class="help">Sem anomalias.</div>';
|
|
1380
|
+
} catch {
|
|
1381
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar anomalias.</div>';
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1157
1385
|
async function doHealth() {
|
|
1158
1386
|
try {
|
|
1159
1387
|
saveLocal();
|
|
@@ -1546,6 +1774,10 @@
|
|
|
1546
1774
|
|
|
1547
1775
|
if (isCompanionPage) {
|
|
1548
1776
|
await refreshHealthChecklist();
|
|
1777
|
+
await refreshQualityScore();
|
|
1778
|
+
await refreshExecutiveSummary();
|
|
1779
|
+
await refreshAnomalies();
|
|
1780
|
+
await refreshRiskSummary();
|
|
1549
1781
|
await refreshIncidents();
|
|
1550
1782
|
await refreshHeatmap();
|
|
1551
1783
|
return;
|
|
@@ -1597,6 +1829,10 @@
|
|
|
1597
1829
|
window.setTimelineKind = setTimelineKind;
|
|
1598
1830
|
window.refreshBlockersInsights = refreshBlockersInsights;
|
|
1599
1831
|
window.refreshHealthChecklist = refreshHealthChecklist;
|
|
1832
|
+
window.refreshQualityScore = refreshQualityScore;
|
|
1833
|
+
window.refreshExecutiveSummary = refreshExecutiveSummary;
|
|
1834
|
+
window.refreshAnomalies = refreshAnomalies;
|
|
1835
|
+
window.refreshRiskSummary = refreshRiskSummary;
|
|
1600
1836
|
window.copyOut = copyOut;
|
|
1601
1837
|
window.copyPath = copyPath;
|
|
1602
1838
|
window.openSelected = openSelected;
|
package/cli/web.js
CHANGED
|
@@ -255,6 +255,57 @@ function splitForDiscord(text, limit = 1900) {
|
|
|
255
255
|
return parts;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
function parseIncidentMarkdown(md) {
|
|
259
|
+
const lines = String(md || '').split(/\r?\n/);
|
|
260
|
+
const items = [];
|
|
261
|
+
let current = null;
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
if (line.startsWith('- **')) {
|
|
264
|
+
if (current) items.push(current);
|
|
265
|
+
current = { title: line.replace('- **', '').replace('**', '').trim(), body: [] };
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (current && line.trim().startsWith('- ')) {
|
|
269
|
+
current.body.push(line.trim().replace(/^- /, ''));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (current) items.push(current);
|
|
273
|
+
return items;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function serializeIncidentMarkdown(items) {
|
|
277
|
+
return (items || []).map((it) => {
|
|
278
|
+
const body = Array.isArray(it.body) ? it.body : [];
|
|
279
|
+
const lines = ['- **' + (it.title || 'Incidente') + '**'];
|
|
280
|
+
for (const b of body) lines.push('- ' + b);
|
|
281
|
+
return lines.join('\n');
|
|
282
|
+
}).join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function resolveIncidentInMarkdown(md, title, index) {
|
|
286
|
+
const items = parseIncidentMarkdown(md);
|
|
287
|
+
const matches = items.map((it, i) => ({ it, i })).filter((x) => x.it.title === title);
|
|
288
|
+
if (!matches.length) return null;
|
|
289
|
+
const pick = typeof index === 'number' && Number.isInteger(index) ? matches[index] : matches[0];
|
|
290
|
+
if (!pick) return null;
|
|
291
|
+
const entry = pick.it;
|
|
292
|
+
const body = Array.isArray(entry.body) ? entry.body : [];
|
|
293
|
+
const statusIdx = body.findIndex((b) => /^status\s*:/i.test(b));
|
|
294
|
+
if (statusIdx >= 0) body[statusIdx] = 'Status: resolved';
|
|
295
|
+
else body.push('Status: resolved');
|
|
296
|
+
entry.body = body;
|
|
297
|
+
return serializeIncidentMarkdown(items);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function normalizePriority(value) {
|
|
301
|
+
const raw = String(value || '').trim().toLowerCase();
|
|
302
|
+
if (!raw) return '';
|
|
303
|
+
if (['high', 'alta', 'alto', 'critical', 'critico', 'crítico'].includes(raw)) return 'high';
|
|
304
|
+
if (['medium', 'media', 'médio', 'medio'].includes(raw)) return 'medium';
|
|
305
|
+
if (['low', 'baixa', 'baixo'].includes(raw)) return 'low';
|
|
306
|
+
return '';
|
|
307
|
+
}
|
|
308
|
+
|
|
258
309
|
function postJson(url, bodyObj) {
|
|
259
310
|
return new Promise((resolve, reject) => {
|
|
260
311
|
const u = new URL(url);
|
|
@@ -1360,6 +1411,11 @@ function buildTimelineHtml(safeDefault, appVersion) {
|
|
|
1360
1411
|
<input id=\"timelineFilter\" placeholder=\"filtrar (tag, projeto, tipo)\" oninput=\"renderTimeline()\" />
|
|
1361
1412
|
</section>
|
|
1362
1413
|
|
|
1414
|
+
<section class=\"reportsTools\" style=\"display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;\">
|
|
1415
|
+
<select id=\"timelineProject\" onchange=\"setTimelineProject(this.value)\"></select>
|
|
1416
|
+
<select id=\"timelineTag\" onchange=\"setTimelineTag(this.value)\"></select>
|
|
1417
|
+
</section>
|
|
1418
|
+
|
|
1363
1419
|
<section class=\"reportsGrid\" id=\"timelineGrid\"></section>
|
|
1364
1420
|
</div>
|
|
1365
1421
|
</main>
|
|
@@ -1443,6 +1499,46 @@ function buildCompanionHtml(safeDefault, appVersion) {
|
|
|
1443
1499
|
|
|
1444
1500
|
<section class="reportsGrid" id="healthChecklist"></section>
|
|
1445
1501
|
|
|
1502
|
+
<section class="panel" style="margin-top:16px">
|
|
1503
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1504
|
+
<b>Qualidade de Log</b>
|
|
1505
|
+
<button class="btn small" type="button" onclick="refreshQualityScore()">Atualizar</button>
|
|
1506
|
+
</div>
|
|
1507
|
+
<div class="panelBody">
|
|
1508
|
+
<div id="qualityScoreCard"></div>
|
|
1509
|
+
</div>
|
|
1510
|
+
</section>
|
|
1511
|
+
|
|
1512
|
+
<section class="panel" style="margin-top:16px">
|
|
1513
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1514
|
+
<b>Resumo Executivo</b>
|
|
1515
|
+
<button class="btn small" type="button" onclick="refreshExecutiveSummary()">Atualizar</button>
|
|
1516
|
+
</div>
|
|
1517
|
+
<div class="panelBody">
|
|
1518
|
+
<div id="executiveSummary" class="help"></div>
|
|
1519
|
+
</div>
|
|
1520
|
+
</section>
|
|
1521
|
+
|
|
1522
|
+
<section class="panel" style="margin-top:16px">
|
|
1523
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1524
|
+
<b>Anomalias</b>
|
|
1525
|
+
<button class="btn small" type="button" onclick="refreshAnomalies()">Atualizar</button>
|
|
1526
|
+
</div>
|
|
1527
|
+
<div class="panelBody">
|
|
1528
|
+
<div id="anomaliesBox"></div>
|
|
1529
|
+
</div>
|
|
1530
|
+
</section>
|
|
1531
|
+
|
|
1532
|
+
<section class="panel" style="margin-top:16px">
|
|
1533
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1534
|
+
<b>Resumo de Risco</b>
|
|
1535
|
+
<button class="btn small" type="button" onclick="refreshRiskSummary()">Atualizar</button>
|
|
1536
|
+
</div>
|
|
1537
|
+
<div class="panelBody">
|
|
1538
|
+
<div id="riskSummary"></div>
|
|
1539
|
+
</div>
|
|
1540
|
+
</section>
|
|
1541
|
+
|
|
1446
1542
|
<section class="panel" style="margin-top:16px">
|
|
1447
1543
|
<div class="panelHead"><b>Incident Radar</b></div>
|
|
1448
1544
|
<div class="panelBody">
|
|
@@ -1621,6 +1717,62 @@ function readJsonOrNull(p) {
|
|
|
1621
1717
|
}
|
|
1622
1718
|
}
|
|
1623
1719
|
|
|
1720
|
+
function truncateText(text, maxLen) {
|
|
1721
|
+
const str = String(text || '').trim();
|
|
1722
|
+
if (str.length <= maxLen) return str;
|
|
1723
|
+
return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function getTimelineItems(workspaceDir) {
|
|
1727
|
+
const items = [];
|
|
1728
|
+
const dailyDir = path.join(workspaceDir, 'logs', 'daily');
|
|
1729
|
+
if (exists(dailyDir)) {
|
|
1730
|
+
const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
1731
|
+
for (const f of files) {
|
|
1732
|
+
const date = f.replace('.md', '');
|
|
1733
|
+
const full = path.join(dailyDir, f);
|
|
1734
|
+
const body = fs.readFileSync(full, 'utf8');
|
|
1735
|
+
items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
1739
|
+
if (exists(base)) {
|
|
1740
|
+
const stack = [base];
|
|
1741
|
+
while (stack.length) {
|
|
1742
|
+
const dirp = stack.pop();
|
|
1743
|
+
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
1744
|
+
for (const ent of entries) {
|
|
1745
|
+
const full = path.join(dirp, ent.name);
|
|
1746
|
+
if (ent.isDirectory()) stack.push(full);
|
|
1747
|
+
else if (ent.isFile() && ent.name === 'status.json') {
|
|
1748
|
+
const doc = readJsonOrNull(full) || {};
|
|
1749
|
+
const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
|
|
1750
|
+
const hist = Array.isArray(doc.history) ? doc.history : [];
|
|
1751
|
+
for (const h of hist) {
|
|
1752
|
+
items.push({
|
|
1753
|
+
kind: 'status',
|
|
1754
|
+
date: h.date || '',
|
|
1755
|
+
title: `${doc.project || slug} (${h.type || 'Status'})`,
|
|
1756
|
+
content: h.content || '',
|
|
1757
|
+
tags: h.tags || [],
|
|
1758
|
+
slug
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
1766
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
1767
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
1768
|
+
for (const t of tasks) {
|
|
1769
|
+
if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
1770
|
+
if (t.completedAt) items.push({ kind: 'task', date: String(t.completedAt).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
1771
|
+
}
|
|
1772
|
+
items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
1773
|
+
return items;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1624
1776
|
function writeJson(p, obj) {
|
|
1625
1777
|
ensureDir(path.dirname(p));
|
|
1626
1778
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
@@ -1935,17 +2087,75 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
1935
2087
|
}
|
|
1936
2088
|
|
|
1937
2089
|
if (req.url === '/api/timeline') {
|
|
1938
|
-
const items =
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2090
|
+
const items = getTimelineItems(workspaceDir);
|
|
2091
|
+
return safeJson(res, 200, { ok: true, items });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
if (req.url === '/api/summary/executive') {
|
|
2095
|
+
const items = getTimelineItems(workspaceDir);
|
|
2096
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
2097
|
+
const recent = items.filter((it) => {
|
|
2098
|
+
const ts = Date.parse(it.date || '');
|
|
2099
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
2100
|
+
});
|
|
2101
|
+
const counts = { status: 0, taskCreated: 0, taskCompleted: 0, daily: 0 };
|
|
2102
|
+
for (const it of recent) {
|
|
2103
|
+
if (it.kind === 'status') counts.status++;
|
|
2104
|
+
else if (it.kind === 'daily') counts.daily++;
|
|
2105
|
+
else if (it.kind === 'task' && String(it.title || '').toLowerCase().includes('concluida')) counts.taskCompleted++;
|
|
2106
|
+
else if (it.kind === 'task') counts.taskCreated++;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2110
|
+
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2111
|
+
const openBlockers = (blockerDoc.blockers || []).filter((b) => b && String(b.status || '').trim() === 'OPEN');
|
|
2112
|
+
|
|
2113
|
+
let summary = '';
|
|
2114
|
+
if (!recent.length) {
|
|
2115
|
+
summary = 'Sem eventos recentes na timeline nos últimos 7 dias.';
|
|
2116
|
+
} else {
|
|
2117
|
+
const parts = [];
|
|
2118
|
+
if (counts.taskCompleted) parts.push(`${counts.taskCompleted} tarefa(s) concluida(s)`);
|
|
2119
|
+
if (counts.taskCreated) parts.push(`${counts.taskCreated} tarefa(s) criada(s)`);
|
|
2120
|
+
if (counts.status) parts.push(`${counts.status} update(s) de status`);
|
|
2121
|
+
if (!parts.length && counts.daily) parts.push(`${counts.daily} daily(s)`);
|
|
2122
|
+
const head = parts.length ? parts.join(', ') : `${recent.length} eventos`;
|
|
2123
|
+
summary = `Resumo: ${head} nos últimos 7 dias.`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const blockersSentence = openBlockers.length
|
|
2127
|
+
? `Há ${openBlockers.length} blocker(s) aberto(s).`
|
|
2128
|
+
: 'Sem blockers abertos.';
|
|
2129
|
+
|
|
2130
|
+
summary = `${summary} ${blockersSentence}`.trim();
|
|
2131
|
+
summary = truncateText(summary, 260);
|
|
2132
|
+
return safeJson(res, 200, { ok: true, summary, stats: { recent: recent.length, openBlockers: openBlockers.length, ...counts } });
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
if (req.url === '/api/quality/score') {
|
|
2136
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
2137
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
|
|
2141
|
+
|
|
2142
|
+
// Tasks with projectSlug
|
|
2143
|
+
let tasksTotal = 0;
|
|
2144
|
+
let tasksWithProject = 0;
|
|
2145
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2146
|
+
if (exists(taskFile)) {
|
|
2147
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2148
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2149
|
+
tasksTotal = tasks.length;
|
|
2150
|
+
for (const t of tasks) {
|
|
2151
|
+
const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
|
|
2152
|
+
if (slug) tasksWithProject++;
|
|
1947
2153
|
}
|
|
1948
2154
|
}
|
|
2155
|
+
|
|
2156
|
+
// Status files with history array
|
|
2157
|
+
let statusTotal = 0;
|
|
2158
|
+
let statusWithHistory = 0;
|
|
1949
2159
|
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
1950
2160
|
if (exists(base)) {
|
|
1951
2161
|
const stack = [base];
|
|
@@ -1956,32 +2166,159 @@ if (req.url === '/api/timeline') {
|
|
|
1956
2166
|
const full = path.join(dirp, ent.name);
|
|
1957
2167
|
if (ent.isDirectory()) stack.push(full);
|
|
1958
2168
|
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2169
|
+
statusTotal++;
|
|
1959
2170
|
const doc = readJsonOrNull(full) || {};
|
|
1960
|
-
|
|
1961
|
-
const hist = Array.isArray(doc.history) ? doc.history : [];
|
|
1962
|
-
for (const h of hist) {
|
|
1963
|
-
items.push({
|
|
1964
|
-
kind: 'status',
|
|
1965
|
-
date: h.date || '',
|
|
1966
|
-
title: `${doc.project || slug} (${h.type || 'Status'})`,
|
|
1967
|
-
content: h.content || '',
|
|
1968
|
-
tags: h.tags || [],
|
|
1969
|
-
slug
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
2171
|
+
if (Array.isArray(doc.history)) statusWithHistory++;
|
|
1972
2172
|
}
|
|
1973
2173
|
}
|
|
1974
2174
|
}
|
|
1975
2175
|
}
|
|
2176
|
+
|
|
2177
|
+
// Blockers with projectSlug
|
|
2178
|
+
let blockersTotal = 0;
|
|
2179
|
+
let blockersWithProject = 0;
|
|
2180
|
+
const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2181
|
+
if (exists(blockersFile)) {
|
|
2182
|
+
const blockersDoc = readJsonOrNull(blockersFile) || { blockers: [] };
|
|
2183
|
+
const blockers = Array.isArray(blockersDoc.blockers) ? blockersDoc.blockers : [];
|
|
2184
|
+
blockersTotal = blockers.length;
|
|
2185
|
+
for (const b of blockers) {
|
|
2186
|
+
const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
|
|
2187
|
+
if (slug) blockersWithProject++;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const breakdown = {
|
|
2192
|
+
tasks: { total: tasksTotal, withProjectSlug: tasksWithProject, pct: pct(tasksWithProject, tasksTotal) },
|
|
2193
|
+
status: { total: statusTotal, withHistory: statusWithHistory, pct: pct(statusWithHistory, statusTotal) },
|
|
2194
|
+
blockers: { total: blockersTotal, withProjectSlug: blockersWithProject, pct: pct(blockersWithProject, blockersTotal) }
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
const scoreParts = [breakdown.tasks.pct, breakdown.status.pct, breakdown.blockers.pct].filter((v) => typeof v === 'number');
|
|
2198
|
+
const score = scoreParts.length ? Math.round((scoreParts.reduce((a, b) => a + b, 0) / scoreParts.length) * 10) / 10 : null;
|
|
2199
|
+
|
|
2200
|
+
return safeJson(res, 200, { ok: true, score, breakdown });
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
if (req.url === '/api/risk/summary') {
|
|
2204
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
2205
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const pendingThreshold = 5;
|
|
2209
|
+
const daysThreshold = 7;
|
|
2210
|
+
const now = Date.now();
|
|
2211
|
+
|
|
2212
|
+
const pendingByProject = {};
|
|
1976
2213
|
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
2214
|
+
if (exists(taskFile)) {
|
|
2215
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2216
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2217
|
+
for (const t of tasks) {
|
|
2218
|
+
if (!t || t.status === 'COMPLETED') continue;
|
|
2219
|
+
const slug = String(t.projectSlug || '').trim();
|
|
2220
|
+
if (!slug) continue;
|
|
2221
|
+
pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
|
|
2222
|
+
}
|
|
1982
2223
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
2224
|
+
|
|
2225
|
+
const blockersByProject = {};
|
|
2226
|
+
const oldestByProject = {};
|
|
2227
|
+
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2228
|
+
if (exists(blockerFile)) {
|
|
2229
|
+
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2230
|
+
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
2231
|
+
for (const b of blockers) {
|
|
2232
|
+
if (!b) continue;
|
|
2233
|
+
const status = String(b.status || '').toUpperCase();
|
|
2234
|
+
if (status !== 'OPEN' && status !== 'MITIGATING') continue;
|
|
2235
|
+
const slug = String(b.projectSlug || '').trim();
|
|
2236
|
+
if (!slug) continue;
|
|
2237
|
+
const createdAt = b.createdAt ? Date.parse(b.createdAt) : null;
|
|
2238
|
+
if (!createdAt) continue;
|
|
2239
|
+
const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
|
|
2240
|
+
if (ageDays < daysThreshold) continue;
|
|
2241
|
+
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2242
|
+
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
|
|
2247
|
+
const items = [];
|
|
2248
|
+
for (const slug of projects) {
|
|
2249
|
+
const pending = pendingByProject[slug] || 0;
|
|
2250
|
+
const oldBlockers = blockersByProject[slug] || 0;
|
|
2251
|
+
const oldestDays = oldestByProject[slug] || null;
|
|
2252
|
+
if (pending <= pendingThreshold && oldBlockers === 0) continue;
|
|
2253
|
+
items.push({ slug, pendingTasks: pending, oldBlockers, oldestBlockerDays: oldestDays });
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
items.sort((a, b) => {
|
|
2257
|
+
if (b.oldBlockers !== a.oldBlockers) return b.oldBlockers - a.oldBlockers;
|
|
2258
|
+
if (b.pendingTasks !== a.pendingTasks) return b.pendingTasks - a.pendingTasks;
|
|
2259
|
+
return (b.oldestBlockerDays || 0) - (a.oldestBlockerDays || 0);
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
return safeJson(res, 200, { ok: true, items: items.slice(0, 5), threshold: { pending: pendingThreshold, days: daysThreshold } });
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (req.url === '/api/anomalies') {
|
|
2266
|
+
const anomalies = {
|
|
2267
|
+
tasksMissingProject: { count: 0, samples: [] },
|
|
2268
|
+
statusMissingHistory: { count: 0, samples: [] }
|
|
2269
|
+
};
|
|
2270
|
+
|
|
2271
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2272
|
+
if (exists(taskFile)) {
|
|
2273
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2274
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2275
|
+
for (const t of tasks) {
|
|
2276
|
+
const slug = String(t.projectSlug || '').trim();
|
|
2277
|
+
if (!slug) {
|
|
2278
|
+
anomalies.tasksMissingProject.count++;
|
|
2279
|
+
if (anomalies.tasksMissingProject.samples.length < 5) {
|
|
2280
|
+
anomalies.tasksMissingProject.samples.push(`data/tasks/task-log.json::${t.id || t.description || 'task'}`);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
2287
|
+
if (exists(base)) {
|
|
2288
|
+
const stack = [base];
|
|
2289
|
+
while (stack.length) {
|
|
2290
|
+
const dirp = stack.pop();
|
|
2291
|
+
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2292
|
+
for (const ent of entries) {
|
|
2293
|
+
const full = path.join(dirp, ent.name);
|
|
2294
|
+
if (ent.isDirectory()) stack.push(full);
|
|
2295
|
+
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2296
|
+
const doc = readJsonOrNull(full) || {};
|
|
2297
|
+
if (!Array.isArray(doc.history)) {
|
|
2298
|
+
anomalies.statusMissingHistory.count++;
|
|
2299
|
+
if (anomalies.statusMissingHistory.samples.length < 5) {
|
|
2300
|
+
anomalies.statusMissingHistory.samples.push(path.relative(workspaceDir, full).replace(/\\/g, '/'));
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
return safeJson(res, 200, { ok: true, anomalies });
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (req.url === '/api/incidents/resolve') {
|
|
2312
|
+
const title = payload.title;
|
|
2313
|
+
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
2314
|
+
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
2315
|
+
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
2316
|
+
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
2317
|
+
const md = fs.readFileSync(p, 'utf8');
|
|
2318
|
+
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
2319
|
+
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
2320
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
2321
|
+
return safeJson(res, 200, { ok: true });
|
|
1985
2322
|
}
|
|
1986
2323
|
|
|
1987
2324
|
if (req.url === '/api/incidents') {
|
|
@@ -1996,13 +2333,20 @@ if (req.url === '/api/timeline') {
|
|
|
1996
2333
|
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
1997
2334
|
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
1998
2335
|
const map = {};
|
|
2336
|
+
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
1999
2337
|
for (const t of tasks) {
|
|
2000
2338
|
const slug = t.projectSlug || 'unassigned';
|
|
2001
|
-
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
|
|
2339
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
2002
2340
|
map[slug].total++;
|
|
2003
2341
|
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
2342
|
+
const p = normalizePriority(t.priority || t.severity);
|
|
2343
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
2004
2344
|
}
|
|
2005
|
-
const items = Object.entries(map).map(([slug, v]) =>
|
|
2345
|
+
const items = Object.entries(map).map(([slug, v]) => {
|
|
2346
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
2347
|
+
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
2348
|
+
return { slug, ...v, linkRel };
|
|
2349
|
+
});
|
|
2006
2350
|
items.sort((a, b) => b.total - a.total);
|
|
2007
2351
|
return safeJson(res, 200, { ok: true, items });
|
|
2008
2352
|
}
|
|
@@ -2075,6 +2419,7 @@ if (req.url === '/api/reports/list') {
|
|
|
2075
2419
|
const lc = textInput.toLowerCase();
|
|
2076
2420
|
const projectsDir = path.join(workspaceDir, 'docs', 'projects');
|
|
2077
2421
|
const links = [];
|
|
2422
|
+
const slugs = [];
|
|
2078
2423
|
|
|
2079
2424
|
if (exists(projectsDir)) {
|
|
2080
2425
|
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith('.md'));
|
|
@@ -2084,7 +2429,10 @@ if (req.url === '/api/reports/list') {
|
|
|
2084
2429
|
const txt = fs.readFileSync(full, 'utf8');
|
|
2085
2430
|
const m = txt.match(/DataPath:\s*data\/Clients\/(.+?)\//i);
|
|
2086
2431
|
const slug = m ? m[1].toLowerCase() : name.toLowerCase();
|
|
2087
|
-
if (lc.includes(slug))
|
|
2432
|
+
if (lc.includes(slug)) {
|
|
2433
|
+
links.push('[[' + name + ']]');
|
|
2434
|
+
slugs.push(slug);
|
|
2435
|
+
}
|
|
2088
2436
|
}
|
|
2089
2437
|
}
|
|
2090
2438
|
|
|
@@ -2099,17 +2447,21 @@ if (req.url === '/api/reports/list') {
|
|
|
2099
2447
|
if (ent.isDirectory()) stack.push(full);
|
|
2100
2448
|
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2101
2449
|
const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/').toLowerCase();
|
|
2102
|
-
if (lc.includes(slug))
|
|
2450
|
+
if (lc.includes(slug)) {
|
|
2451
|
+
links.push('[[' + slug + ']]');
|
|
2452
|
+
slugs.push(slug);
|
|
2453
|
+
}
|
|
2103
2454
|
}
|
|
2104
2455
|
}
|
|
2105
2456
|
}
|
|
2106
2457
|
}
|
|
2107
2458
|
|
|
2108
|
-
const
|
|
2109
|
-
|
|
2110
|
-
|
|
2459
|
+
const uniqLinks = Array.from(new Set(links));
|
|
2460
|
+
const uniqSlugs = Array.from(new Set(slugs));
|
|
2461
|
+
const linksText = uniqLinks.length ? ('\n\nLinks: ' + uniqLinks.join(' ')) : '';
|
|
2462
|
+
return { linksText, slugs: uniqSlugs };
|
|
2111
2463
|
} catch {
|
|
2112
|
-
return '';
|
|
2464
|
+
return { linksText: '', slugs: [] };
|
|
2113
2465
|
}
|
|
2114
2466
|
}
|
|
2115
2467
|
|
|
@@ -2125,9 +2477,33 @@ if (req.url === '/api/reports/list') {
|
|
|
2125
2477
|
const hh = String(stamp.getHours()).padStart(2, '0');
|
|
2126
2478
|
const mm = String(stamp.getMinutes()).padStart(2, '0');
|
|
2127
2479
|
|
|
2128
|
-
const
|
|
2480
|
+
const linkInfo = autoLinkNotes(text);
|
|
2481
|
+
const linksText = linkInfo && linkInfo.linksText ? linkInfo.linksText : '';
|
|
2482
|
+
const slugs = linkInfo && Array.isArray(linkInfo.slugs) ? linkInfo.slugs : [];
|
|
2483
|
+
|
|
2484
|
+
const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}${linksText}\n`;
|
|
2129
2485
|
fs.appendFileSync(file, block, 'utf8');
|
|
2130
2486
|
|
|
2487
|
+
if (slugs.length) {
|
|
2488
|
+
const logRel = path.relative(workspaceDir, file).replace(/\\/g, '/');
|
|
2489
|
+
const stampText = `${d} ${hh}:${mm}`;
|
|
2490
|
+
for (const slug of slugs) {
|
|
2491
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
2492
|
+
if (!exists(statusPath)) continue;
|
|
2493
|
+
const doc = readJsonOrNull(statusPath) || { history: [] };
|
|
2494
|
+
if (!Array.isArray(doc.history)) doc.history = [];
|
|
2495
|
+
const already = doc.history.some((h) => h && (String(h.source || '').includes(logRel) || String(h.content || '').includes(logRel)));
|
|
2496
|
+
if (already) continue;
|
|
2497
|
+
doc.history.push({
|
|
2498
|
+
date: isoNow(),
|
|
2499
|
+
type: 'Log',
|
|
2500
|
+
content: `Log entry ${stampText} (${logRel})`,
|
|
2501
|
+
source: logRel
|
|
2502
|
+
});
|
|
2503
|
+
writeJson(statusPath, doc);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2131
2507
|
return safeJson(res, 200, { ok: true, file: path.relative(workspaceDir, file).replace(/\\/g, '/'), appended: true });
|
|
2132
2508
|
}
|
|
2133
2509
|
|
|
@@ -2788,6 +3164,19 @@ if (req.url === '/api/reports/list') {
|
|
|
2788
3164
|
return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
|
|
2789
3165
|
}
|
|
2790
3166
|
|
|
3167
|
+
if (req.url === '/api/incidents/resolve') {
|
|
3168
|
+
const title = payload.title;
|
|
3169
|
+
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
3170
|
+
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
3171
|
+
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
3172
|
+
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
3173
|
+
const md = fs.readFileSync(p, 'utf8');
|
|
3174
|
+
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
3175
|
+
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
3176
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
3177
|
+
return safeJson(res, 200, { ok: true });
|
|
3178
|
+
}
|
|
3179
|
+
|
|
2791
3180
|
if (req.url === '/api/incidents') {
|
|
2792
3181
|
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
2793
3182
|
if (!exists(p)) return safeJson(res, 200, { ok: true, markdown: '' });
|
|
@@ -2800,13 +3189,20 @@ if (req.url === '/api/reports/list') {
|
|
|
2800
3189
|
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
2801
3190
|
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
2802
3191
|
const map = {};
|
|
3192
|
+
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
2803
3193
|
for (const t of tasks) {
|
|
2804
3194
|
const slug = t.projectSlug || 'unassigned';
|
|
2805
|
-
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
|
|
3195
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
2806
3196
|
map[slug].total++;
|
|
2807
3197
|
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
3198
|
+
const p = normalizePriority(t.priority || t.severity);
|
|
3199
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
2808
3200
|
}
|
|
2809
|
-
const items = Object.entries(map).map(([slug, v]) =>
|
|
3201
|
+
const items = Object.entries(map).map(([slug, v]) => {
|
|
3202
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
3203
|
+
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
3204
|
+
return { slug, ...v, linkRel };
|
|
3205
|
+
});
|
|
2810
3206
|
items.sort((a, b) => b.total - a.total);
|
|
2811
3207
|
return safeJson(res, 200, { ok: true, items });
|
|
2812
3208
|
}
|
package/package.json
CHANGED