@cccarv82/freya 2.3.5 → 2.3.7
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 +167 -3
- package/cli/web.js +247 -33
- 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,52 @@
|
|
|
1154
1268
|
}
|
|
1155
1269
|
}
|
|
1156
1270
|
|
|
1271
|
+
async function refreshExecutiveSummary() {
|
|
1272
|
+
const el = $('executiveSummary');
|
|
1273
|
+
if (el) el.textContent = 'Carregando resumo...';
|
|
1274
|
+
try {
|
|
1275
|
+
const r = await api('/api/summary/executive', { dir: dirOrDefault() });
|
|
1276
|
+
if (!el) return;
|
|
1277
|
+
el.textContent = r.summary || 'Sem resumo disponível.';
|
|
1278
|
+
} catch {
|
|
1279
|
+
if (el) el.textContent = 'Falha ao carregar resumo.';
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function refreshAnomalies() {
|
|
1284
|
+
const el = $('anomaliesBox');
|
|
1285
|
+
if (el) el.innerHTML = '<div class="help">Carregando anomalias...</div>';
|
|
1286
|
+
try {
|
|
1287
|
+
const r = await api('/api/anomalies', { dir: dirOrDefault() });
|
|
1288
|
+
if (!el) return;
|
|
1289
|
+
const anomalies = (r && r.anomalies) ? r.anomalies : {};
|
|
1290
|
+
const tasksMissing = anomalies.tasksMissingProject || { count: 0, samples: [] };
|
|
1291
|
+
const statusMissing = anomalies.statusMissingHistory || { count: 0, samples: [] };
|
|
1292
|
+
|
|
1293
|
+
const rows = [];
|
|
1294
|
+
const pushRow = (label, data) => {
|
|
1295
|
+
const status = data.count > 0 ? 'warn' : 'ok';
|
|
1296
|
+
const samples = (data.samples || []).slice(0, 5).map((s) => `<div class=\"help\" style=\"margin-top:4px\">${escapeHtml(s)}</div>`).join('');
|
|
1297
|
+
rows.push(
|
|
1298
|
+
`<div class=\"rep\">`
|
|
1299
|
+
+ `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1300
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(label)}</div>`
|
|
1301
|
+
+ `<div class=\"help\" style=\"margin-top:4px\">${data.count} ocorrência(s)</div>`
|
|
1302
|
+
+ `${samples}</div>`
|
|
1303
|
+
+ `<div class=\"pill ${status}\">${status}</div>`
|
|
1304
|
+
+ `</div>`
|
|
1305
|
+
+ `</div>`
|
|
1306
|
+
);
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
pushRow('Tarefas sem projectSlug', tasksMissing);
|
|
1310
|
+
pushRow('Status sem history', statusMissing);
|
|
1311
|
+
el.innerHTML = rows.join('') || '<div class="help">Sem anomalias.</div>';
|
|
1312
|
+
} catch {
|
|
1313
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar anomalias.</div>';
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1157
1317
|
async function doHealth() {
|
|
1158
1318
|
try {
|
|
1159
1319
|
saveLocal();
|
|
@@ -1546,6 +1706,8 @@
|
|
|
1546
1706
|
|
|
1547
1707
|
if (isCompanionPage) {
|
|
1548
1708
|
await refreshHealthChecklist();
|
|
1709
|
+
await refreshExecutiveSummary();
|
|
1710
|
+
await refreshAnomalies();
|
|
1549
1711
|
await refreshIncidents();
|
|
1550
1712
|
await refreshHeatmap();
|
|
1551
1713
|
return;
|
|
@@ -1597,6 +1759,8 @@
|
|
|
1597
1759
|
window.setTimelineKind = setTimelineKind;
|
|
1598
1760
|
window.refreshBlockersInsights = refreshBlockersInsights;
|
|
1599
1761
|
window.refreshHealthChecklist = refreshHealthChecklist;
|
|
1762
|
+
window.refreshExecutiveSummary = refreshExecutiveSummary;
|
|
1763
|
+
window.refreshAnomalies = refreshAnomalies;
|
|
1600
1764
|
window.copyOut = copyOut;
|
|
1601
1765
|
window.copyPath = copyPath;
|
|
1602
1766
|
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,26 @@ 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>Resumo Executivo</b>
|
|
1505
|
+
<button class="btn small" type="button" onclick="refreshExecutiveSummary()">Atualizar</button>
|
|
1506
|
+
</div>
|
|
1507
|
+
<div class="panelBody">
|
|
1508
|
+
<div id="executiveSummary" class="help"></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>Anomalias</b>
|
|
1515
|
+
<button class="btn small" type="button" onclick="refreshAnomalies()">Atualizar</button>
|
|
1516
|
+
</div>
|
|
1517
|
+
<div class="panelBody">
|
|
1518
|
+
<div id="anomaliesBox"></div>
|
|
1519
|
+
</div>
|
|
1520
|
+
</section>
|
|
1521
|
+
|
|
1446
1522
|
<section class="panel" style="margin-top:16px">
|
|
1447
1523
|
<div class="panelHead"><b>Incident Radar</b></div>
|
|
1448
1524
|
<div class="panelBody">
|
|
@@ -1621,6 +1697,62 @@ function readJsonOrNull(p) {
|
|
|
1621
1697
|
}
|
|
1622
1698
|
}
|
|
1623
1699
|
|
|
1700
|
+
function truncateText(text, maxLen) {
|
|
1701
|
+
const str = String(text || '').trim();
|
|
1702
|
+
if (str.length <= maxLen) return str;
|
|
1703
|
+
return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function getTimelineItems(workspaceDir) {
|
|
1707
|
+
const items = [];
|
|
1708
|
+
const dailyDir = path.join(workspaceDir, 'logs', 'daily');
|
|
1709
|
+
if (exists(dailyDir)) {
|
|
1710
|
+
const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
1711
|
+
for (const f of files) {
|
|
1712
|
+
const date = f.replace('.md', '');
|
|
1713
|
+
const full = path.join(dailyDir, f);
|
|
1714
|
+
const body = fs.readFileSync(full, 'utf8');
|
|
1715
|
+
items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
1719
|
+
if (exists(base)) {
|
|
1720
|
+
const stack = [base];
|
|
1721
|
+
while (stack.length) {
|
|
1722
|
+
const dirp = stack.pop();
|
|
1723
|
+
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
1724
|
+
for (const ent of entries) {
|
|
1725
|
+
const full = path.join(dirp, ent.name);
|
|
1726
|
+
if (ent.isDirectory()) stack.push(full);
|
|
1727
|
+
else if (ent.isFile() && ent.name === 'status.json') {
|
|
1728
|
+
const doc = readJsonOrNull(full) || {};
|
|
1729
|
+
const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
|
|
1730
|
+
const hist = Array.isArray(doc.history) ? doc.history : [];
|
|
1731
|
+
for (const h of hist) {
|
|
1732
|
+
items.push({
|
|
1733
|
+
kind: 'status',
|
|
1734
|
+
date: h.date || '',
|
|
1735
|
+
title: `${doc.project || slug} (${h.type || 'Status'})`,
|
|
1736
|
+
content: h.content || '',
|
|
1737
|
+
tags: h.tags || [],
|
|
1738
|
+
slug
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
1746
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
1747
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
1748
|
+
for (const t of tasks) {
|
|
1749
|
+
if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
1750
|
+
if (t.completedAt) items.push({ kind: 'task', date: String(t.completedAt).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.projectSlug || '' });
|
|
1751
|
+
}
|
|
1752
|
+
items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
1753
|
+
return items;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1624
1756
|
function writeJson(p, obj) {
|
|
1625
1757
|
ensureDir(path.dirname(p));
|
|
1626
1758
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
@@ -1935,17 +2067,72 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
1935
2067
|
}
|
|
1936
2068
|
|
|
1937
2069
|
if (req.url === '/api/timeline') {
|
|
1938
|
-
const items =
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2070
|
+
const items = getTimelineItems(workspaceDir);
|
|
2071
|
+
return safeJson(res, 200, { ok: true, items });
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
if (req.url === '/api/summary/executive') {
|
|
2075
|
+
const items = getTimelineItems(workspaceDir);
|
|
2076
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
2077
|
+
const recent = items.filter((it) => {
|
|
2078
|
+
const ts = Date.parse(it.date || '');
|
|
2079
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
2080
|
+
});
|
|
2081
|
+
const counts = { status: 0, taskCreated: 0, taskCompleted: 0, daily: 0 };
|
|
2082
|
+
for (const it of recent) {
|
|
2083
|
+
if (it.kind === 'status') counts.status++;
|
|
2084
|
+
else if (it.kind === 'daily') counts.daily++;
|
|
2085
|
+
else if (it.kind === 'task' && String(it.title || '').toLowerCase().includes('concluida')) counts.taskCompleted++;
|
|
2086
|
+
else if (it.kind === 'task') counts.taskCreated++;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2090
|
+
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2091
|
+
const openBlockers = (blockerDoc.blockers || []).filter((b) => b && String(b.status || '').trim() === 'OPEN');
|
|
2092
|
+
|
|
2093
|
+
let summary = '';
|
|
2094
|
+
if (!recent.length) {
|
|
2095
|
+
summary = 'Sem eventos recentes na timeline nos últimos 7 dias.';
|
|
2096
|
+
} else {
|
|
2097
|
+
const parts = [];
|
|
2098
|
+
if (counts.taskCompleted) parts.push(`${counts.taskCompleted} tarefa(s) concluida(s)`);
|
|
2099
|
+
if (counts.taskCreated) parts.push(`${counts.taskCreated} tarefa(s) criada(s)`);
|
|
2100
|
+
if (counts.status) parts.push(`${counts.status} update(s) de status`);
|
|
2101
|
+
if (!parts.length && counts.daily) parts.push(`${counts.daily} daily(s)`);
|
|
2102
|
+
const head = parts.length ? parts.join(', ') : `${recent.length} eventos`;
|
|
2103
|
+
summary = `Resumo: ${head} nos últimos 7 dias.`;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const blockersSentence = openBlockers.length
|
|
2107
|
+
? `Há ${openBlockers.length} blocker(s) aberto(s).`
|
|
2108
|
+
: 'Sem blockers abertos.';
|
|
2109
|
+
|
|
2110
|
+
summary = `${summary} ${blockersSentence}`.trim();
|
|
2111
|
+
summary = truncateText(summary, 260);
|
|
2112
|
+
return safeJson(res, 200, { ok: true, summary, stats: { recent: recent.length, openBlockers: openBlockers.length, ...counts } });
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (req.url === '/api/anomalies') {
|
|
2116
|
+
const anomalies = {
|
|
2117
|
+
tasksMissingProject: { count: 0, samples: [] },
|
|
2118
|
+
statusMissingHistory: { count: 0, samples: [] }
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2122
|
+
if (exists(taskFile)) {
|
|
2123
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2124
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2125
|
+
for (const t of tasks) {
|
|
2126
|
+
const slug = String(t.projectSlug || '').trim();
|
|
2127
|
+
if (!slug) {
|
|
2128
|
+
anomalies.tasksMissingProject.count++;
|
|
2129
|
+
if (anomalies.tasksMissingProject.samples.length < 5) {
|
|
2130
|
+
anomalies.tasksMissingProject.samples.push(`data/tasks/task-log.json::${t.id || t.description || 'task'}`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1947
2133
|
}
|
|
1948
2134
|
}
|
|
2135
|
+
|
|
1949
2136
|
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
1950
2137
|
if (exists(base)) {
|
|
1951
2138
|
const stack = [base];
|
|
@@ -1957,31 +2144,31 @@ if (req.url === '/api/timeline') {
|
|
|
1957
2144
|
if (ent.isDirectory()) stack.push(full);
|
|
1958
2145
|
else if (ent.isFile() && ent.name === 'status.json') {
|
|
1959
2146
|
const doc = readJsonOrNull(full) || {};
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
date: h.date || '',
|
|
1966
|
-
title: `${doc.project || slug} (${h.type || 'Status'})`,
|
|
1967
|
-
content: h.content || '',
|
|
1968
|
-
tags: h.tags || [],
|
|
1969
|
-
slug
|
|
1970
|
-
});
|
|
2147
|
+
if (!Array.isArray(doc.history)) {
|
|
2148
|
+
anomalies.statusMissingHistory.count++;
|
|
2149
|
+
if (anomalies.statusMissingHistory.samples.length < 5) {
|
|
2150
|
+
anomalies.statusMissingHistory.samples.push(path.relative(workspaceDir, full).replace(/\\/g, '/'));
|
|
2151
|
+
}
|
|
1971
2152
|
}
|
|
1972
2153
|
}
|
|
1973
2154
|
}
|
|
1974
2155
|
}
|
|
1975
2156
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2157
|
+
|
|
2158
|
+
return safeJson(res, 200, { ok: true, anomalies });
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
if (req.url === '/api/incidents/resolve') {
|
|
2162
|
+
const title = payload.title;
|
|
2163
|
+
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
2164
|
+
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
2165
|
+
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
2166
|
+
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
2167
|
+
const md = fs.readFileSync(p, 'utf8');
|
|
2168
|
+
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
2169
|
+
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
2170
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
2171
|
+
return safeJson(res, 200, { ok: true });
|
|
1985
2172
|
}
|
|
1986
2173
|
|
|
1987
2174
|
if (req.url === '/api/incidents') {
|
|
@@ -1996,13 +2183,20 @@ if (req.url === '/api/timeline') {
|
|
|
1996
2183
|
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
1997
2184
|
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
1998
2185
|
const map = {};
|
|
2186
|
+
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
1999
2187
|
for (const t of tasks) {
|
|
2000
2188
|
const slug = t.projectSlug || 'unassigned';
|
|
2001
|
-
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
|
|
2189
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
2002
2190
|
map[slug].total++;
|
|
2003
2191
|
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
2192
|
+
const p = normalizePriority(t.priority || t.severity);
|
|
2193
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
2004
2194
|
}
|
|
2005
|
-
const items = Object.entries(map).map(([slug, v]) =>
|
|
2195
|
+
const items = Object.entries(map).map(([slug, v]) => {
|
|
2196
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
2197
|
+
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
2198
|
+
return { slug, ...v, linkRel };
|
|
2199
|
+
});
|
|
2006
2200
|
items.sort((a, b) => b.total - a.total);
|
|
2007
2201
|
return safeJson(res, 200, { ok: true, items });
|
|
2008
2202
|
}
|
|
@@ -2788,6 +2982,19 @@ if (req.url === '/api/reports/list') {
|
|
|
2788
2982
|
return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
|
|
2789
2983
|
}
|
|
2790
2984
|
|
|
2985
|
+
if (req.url === '/api/incidents/resolve') {
|
|
2986
|
+
const title = payload.title;
|
|
2987
|
+
const index = Number.isInteger(payload.index) ? payload.index : null;
|
|
2988
|
+
if (!title) return safeJson(res, 400, { error: 'Missing title' });
|
|
2989
|
+
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
2990
|
+
if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
|
|
2991
|
+
const md = fs.readFileSync(p, 'utf8');
|
|
2992
|
+
const updated = resolveIncidentInMarkdown(md, title, index);
|
|
2993
|
+
if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
|
|
2994
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
2995
|
+
return safeJson(res, 200, { ok: true });
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2791
2998
|
if (req.url === '/api/incidents') {
|
|
2792
2999
|
const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
|
|
2793
3000
|
if (!exists(p)) return safeJson(res, 200, { ok: true, markdown: '' });
|
|
@@ -2800,13 +3007,20 @@ if (req.url === '/api/reports/list') {
|
|
|
2800
3007
|
const doc = readJsonOrNull(file) || { tasks: [] };
|
|
2801
3008
|
const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
|
|
2802
3009
|
const map = {};
|
|
3010
|
+
const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
|
|
2803
3011
|
for (const t of tasks) {
|
|
2804
3012
|
const slug = t.projectSlug || 'unassigned';
|
|
2805
|
-
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
|
|
3013
|
+
if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
|
|
2806
3014
|
map[slug].total++;
|
|
2807
3015
|
if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
|
|
3016
|
+
const p = normalizePriority(t.priority || t.severity);
|
|
3017
|
+
if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
|
|
2808
3018
|
}
|
|
2809
|
-
const items = Object.entries(map).map(([slug, v]) =>
|
|
3019
|
+
const items = Object.entries(map).map(([slug, v]) => {
|
|
3020
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
3021
|
+
const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
|
|
3022
|
+
return { slug, ...v, linkRel };
|
|
3023
|
+
});
|
|
2810
3024
|
items.sort((a, b) => b.total - a.total);
|
|
2811
3025
|
return safeJson(res, 200, { ok: true, items });
|
|
2812
3026
|
}
|
package/package.json
CHANGED