@xera-ai/core 0.11.1 → 0.11.3
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/dist/bin/internal.js +12 -9
- package/dist/bin/templates/coverage-panel.html.fragment +10 -1
- package/dist/bin/templates/graph.css +805 -32
- package/dist/bin/templates/graph.js +510 -90
- package/package.json +3 -3
- package/src/graph/enrich.ts +25 -10
- package/src/graph/render.ts +1 -1
- package/src/graph/templates/coverage-panel.html.fragment +10 -1
- package/src/graph/templates/graph.css +805 -32
- package/src/graph/templates/graph.js +510 -90
|
@@ -143,6 +143,16 @@
|
|
|
143
143
|
var nodes = new vis.DataSet(nodeData);
|
|
144
144
|
var edges = new vis.DataSet(edgeData);
|
|
145
145
|
|
|
146
|
+
// ── Pre-compute adjacency + cache arrays for O(1) lookups ───
|
|
147
|
+
var adjacency = Object.create(null);
|
|
148
|
+
for (const n of nodeData) adjacency[n.id] = new Set();
|
|
149
|
+
for (const er of edgeData) {
|
|
150
|
+
if (adjacency[er.from]) adjacency[er.from].add(er.to);
|
|
151
|
+
if (adjacency[er.to]) adjacency[er.to].add(er.from);
|
|
152
|
+
}
|
|
153
|
+
var allNodeIds = nodeData.map((n) => n.id);
|
|
154
|
+
var edgeIndex = edgeData.map((e) => ({ id: e.id, from: e.from, to: e.to, baseColor: e.color }));
|
|
155
|
+
|
|
146
156
|
// ── Network init ─────────────────────────────────────
|
|
147
157
|
var network = new vis.Network(
|
|
148
158
|
container,
|
|
@@ -177,18 +187,51 @@
|
|
|
177
187
|
},
|
|
178
188
|
);
|
|
179
189
|
|
|
190
|
+
container.style.opacity = '0';
|
|
191
|
+
|
|
192
|
+
// ── Physics state machine (guarded to avoid redundant setOptions) ───
|
|
193
|
+
var physicsOn = true; // initially true during stabilization
|
|
194
|
+
function setPhysics(on) {
|
|
195
|
+
if (physicsOn === on) return;
|
|
196
|
+
physicsOn = on;
|
|
197
|
+
network.setOptions({ physics: { enabled: on } });
|
|
198
|
+
}
|
|
199
|
+
|
|
180
200
|
// ── Progress bar ─────────────────────────────────────
|
|
181
201
|
var progressBar = document.getElementById('progress-bar');
|
|
182
202
|
network.on('stabilizationProgress', (p) => {
|
|
183
203
|
progressBar.style.width = `${Math.round((p.iterations / p.total) * 100)}%`;
|
|
184
204
|
});
|
|
185
205
|
network.once('stabilizationIterationsDone', () => {
|
|
206
|
+
setPhysics(false);
|
|
207
|
+
network.fit();
|
|
208
|
+
container.style.transition = 'opacity 0.3s';
|
|
209
|
+
container.style.opacity = '1';
|
|
186
210
|
progressBar.style.width = '100%';
|
|
187
211
|
setTimeout(() => {
|
|
188
212
|
progressBar.style.transition = 'opacity .4s';
|
|
189
213
|
progressBar.style.opacity = '0';
|
|
190
214
|
}, 300);
|
|
191
|
-
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Drag → temporarily enable physics so connected nodes react ───
|
|
218
|
+
var _disableTimer = null;
|
|
219
|
+
var _enableTimer = null;
|
|
220
|
+
network.on('dragStart', (params) => {
|
|
221
|
+
if (!params.nodes.length) return;
|
|
222
|
+
clearTimeout(_disableTimer);
|
|
223
|
+
clearTimeout(_enableTimer);
|
|
224
|
+
// Only enable on real drags (held > ~80ms) — clicks fire dragStart+dragEnd instantly
|
|
225
|
+
_enableTimer = setTimeout(() => {
|
|
226
|
+
setPhysics(true);
|
|
227
|
+
}, 80);
|
|
228
|
+
});
|
|
229
|
+
network.on('dragEnd', (params) => {
|
|
230
|
+
clearTimeout(_enableTimer);
|
|
231
|
+
if (!params.nodes.length) return;
|
|
232
|
+
_disableTimer = setTimeout(() => {
|
|
233
|
+
setPhysics(false);
|
|
234
|
+
}, 1200);
|
|
192
235
|
});
|
|
193
236
|
|
|
194
237
|
// ── Side panel ───────────────────────────────────────
|
|
@@ -239,82 +282,145 @@
|
|
|
239
282
|
sidepanel.classList.remove('hidden');
|
|
240
283
|
}
|
|
241
284
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
var keep = new Set([nodeId
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
285
|
+
// ── Highlight / dim state machine ────────────────────────────
|
|
286
|
+
// Uses pre-computed adjacency + cached id arrays for O(1) neighbor lookup
|
|
287
|
+
// and a single batched DataSet update per state change.
|
|
288
|
+
var dimmedFor = null; // currently dimmed-for node id, or null
|
|
289
|
+
var pendingDim; // undefined = no pending; null = clear; string = dim for id
|
|
290
|
+
var pendingRaf = 0;
|
|
291
|
+
|
|
292
|
+
function neighborSet(nodeId) {
|
|
293
|
+
var keep = new Set([nodeId]);
|
|
294
|
+
var hop1 = adjacency[nodeId];
|
|
295
|
+
if (!hop1) return keep;
|
|
296
|
+
hop1.forEach((x) => {
|
|
297
|
+
keep.add(x);
|
|
298
|
+
var a = adjacency[x];
|
|
299
|
+
if (a)
|
|
300
|
+
a.forEach((y) => {
|
|
301
|
+
keep.add(y);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
return keep;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function applyDim(nodeId) {
|
|
308
|
+
if (dimmedFor === nodeId) return;
|
|
309
|
+
dimmedFor = nodeId;
|
|
310
|
+
var keep = neighborSet(nodeId);
|
|
311
|
+
nodes.update(allNodeIds.map((id) => ({ id: id, opacity: keep.has(id) ? 1 : 0.15 })));
|
|
312
|
+
edges.update(
|
|
313
|
+
edgeIndex.map((e) => ({
|
|
257
314
|
id: e.id,
|
|
258
|
-
color: Object.assign({}, e.
|
|
259
|
-
|
|
260
|
-
|
|
315
|
+
color: Object.assign({}, e.baseColor, {
|
|
316
|
+
opacity: keep.has(e.from) && keep.has(e.to) ? 0.8 : 0.04,
|
|
317
|
+
}),
|
|
318
|
+
})),
|
|
319
|
+
);
|
|
261
320
|
}
|
|
262
321
|
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
322
|
+
function clearDim() {
|
|
323
|
+
if (dimmedFor === null) return;
|
|
324
|
+
dimmedFor = null;
|
|
325
|
+
nodes.update(allNodeIds.map((id) => ({ id: id, opacity: 1 })));
|
|
326
|
+
edges.update(edgeIndex.map((e) => ({ id: e.id, color: e.baseColor })));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Schedule dim work in next animation frame so the panel renders first.
|
|
330
|
+
// Debounced: if multiple state changes happen before the frame, only the
|
|
331
|
+
// latest wins (prevents flicker on rapid clicks).
|
|
332
|
+
function scheduleDim(nodeIdOrNull) {
|
|
333
|
+
pendingDim = nodeIdOrNull;
|
|
334
|
+
if (pendingRaf) return;
|
|
335
|
+
pendingRaf = requestAnimationFrame(() => {
|
|
336
|
+
pendingRaf = 0;
|
|
337
|
+
var target = pendingDim;
|
|
338
|
+
pendingDim = undefined;
|
|
339
|
+
if (target === null) clearDim();
|
|
340
|
+
else if (typeof target === 'string') applyDim(target);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function hidePanel() {
|
|
270
345
|
sidepanel.classList.add('hidden');
|
|
271
346
|
}
|
|
272
347
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
348
|
+
// ── Selection events ─────────────────────────────────
|
|
349
|
+
// Use selectNode/deselectNode — these only fire on actual selection changes,
|
|
350
|
+
// unlike `click` which also fires after pan/drag.
|
|
351
|
+
var _deselectTimer = null;
|
|
352
|
+
|
|
353
|
+
network.on('selectNode', (params) => {
|
|
354
|
+
clearTimeout(_deselectTimer);
|
|
355
|
+
var id = params.nodes[0];
|
|
356
|
+
showPanel(id); // synchronous, fast — panel appears immediately
|
|
357
|
+
scheduleDim(id); // heavy dim work deferred to next frame
|
|
280
358
|
});
|
|
281
359
|
|
|
360
|
+
network.on('deselectNode', () => {
|
|
361
|
+
// Defer so that switching directly between nodes (deselect→select)
|
|
362
|
+
// doesn't flash the panel closed in between.
|
|
363
|
+
_deselectTimer = setTimeout(() => {
|
|
364
|
+
hidePanel();
|
|
365
|
+
scheduleDim(null);
|
|
366
|
+
}, 0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function resetView() {
|
|
370
|
+
network.unselectAll();
|
|
371
|
+
clearTimeout(_deselectTimer);
|
|
372
|
+
hidePanel();
|
|
373
|
+
scheduleDim(null);
|
|
374
|
+
}
|
|
375
|
+
|
|
282
376
|
// ── Controls ─────────────────────────────────────────
|
|
283
377
|
document.getElementById('reset-btn').onclick = () => {
|
|
284
378
|
resetView();
|
|
285
379
|
network.fit({ animation: { duration: 350, easingFunction: 'easeInOutQuad' } });
|
|
286
380
|
};
|
|
287
381
|
|
|
382
|
+
// Index for fast search lookup
|
|
383
|
+
var searchIndex = data.nodes.map((n) => ({
|
|
384
|
+
id: n.id,
|
|
385
|
+
hay: `${String(n.id)} ${n.label || ''} ${n.title || ''}`.toLowerCase(),
|
|
386
|
+
}));
|
|
288
387
|
document.getElementById('search').oninput = (e) => {
|
|
289
|
-
const q = e.target.value.toLowerCase();
|
|
388
|
+
const q = e.target.value.toLowerCase().trim();
|
|
290
389
|
if (!q) {
|
|
291
390
|
resetView();
|
|
292
391
|
return;
|
|
293
392
|
}
|
|
294
|
-
|
|
295
|
-
const orig = data.nodes.find((x) => x.id === n.id);
|
|
296
|
-
const hit =
|
|
297
|
-
String(n.id).toLowerCase().includes(q) ||
|
|
298
|
-
(orig?.label ?? '').toLowerCase().includes(q) ||
|
|
299
|
-
(orig?.title ?? '').toLowerCase().includes(q);
|
|
300
|
-
nodes.update({ id: n.id, opacity: hit ? 1 : 0.08 });
|
|
301
|
-
}
|
|
393
|
+
nodes.update(searchIndex.map((n) => ({ id: n.id, opacity: n.hay.includes(q) ? 1 : 0.08 })));
|
|
302
394
|
};
|
|
303
395
|
|
|
396
|
+
// Index scenarios by pass/fail for fast filtering
|
|
397
|
+
var scenarioIndex = data.nodes
|
|
398
|
+
.filter((n) => n.group === 'Scenario')
|
|
399
|
+
.map((n) => ({ id: n.id, isPass: n.color === '#10B981', isFail: n.color === '#EF4444' }));
|
|
400
|
+
function applyFilters() {
|
|
401
|
+
var pass = document.getElementById('filter-pass').checked;
|
|
402
|
+
var fail = document.getElementById('filter-fail').checked;
|
|
403
|
+
if (!scenarioIndex.length) return;
|
|
404
|
+
nodes.update(
|
|
405
|
+
scenarioIndex.map((n) => ({ id: n.id, hidden: (n.isPass && !pass) || (n.isFail && !fail) })),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
304
408
|
['filter-pass', 'filter-fail', 'filter-p0'].forEach((id) => {
|
|
305
|
-
document.getElementById(id).onchange =
|
|
306
|
-
const pass = document.getElementById('filter-pass').checked;
|
|
307
|
-
const fail = document.getElementById('filter-fail').checked;
|
|
308
|
-
for (const n of nodes.get()) {
|
|
309
|
-
if (n.group !== 'Scenario') continue;
|
|
310
|
-
const orig = data.nodes.find((x) => x.id === n.id);
|
|
311
|
-
const isPass = orig?.color === '#10B981';
|
|
312
|
-
const isFail = orig?.color === '#EF4444';
|
|
313
|
-
const hidden = (isPass && !pass) || (isFail && !fail);
|
|
314
|
-
nodes.update({ id: n.id, hidden });
|
|
315
|
-
}
|
|
316
|
-
};
|
|
409
|
+
document.getElementById(id).onchange = applyFilters;
|
|
317
410
|
});
|
|
411
|
+
|
|
412
|
+
// ── Cross-tab navigation hook (used by Coverage drawer) ───
|
|
413
|
+
window.__xeraFocus = (id) => {
|
|
414
|
+
if (!id || !nodes.get(id)) return;
|
|
415
|
+
network.unselectAll();
|
|
416
|
+
network.selectNodes([id]);
|
|
417
|
+
showPanel(id);
|
|
418
|
+
scheduleDim(id);
|
|
419
|
+
network.focus(id, {
|
|
420
|
+
scale: 1.3,
|
|
421
|
+
animation: { duration: 450, easingFunction: 'easeInOutQuad' },
|
|
422
|
+
});
|
|
423
|
+
};
|
|
318
424
|
})();
|
|
319
425
|
|
|
320
426
|
// v0.8.1 — top-level tab switching
|
|
@@ -375,41 +481,297 @@ function renderCoverageOnce() {
|
|
|
375
481
|
renderCoverageMap();
|
|
376
482
|
}
|
|
377
483
|
|
|
378
|
-
// Task 27 — coverage map:
|
|
484
|
+
// Task 27 — coverage map: QA action queue (3 sections + drawer)
|
|
485
|
+
const COV_STATUS_THEME = {
|
|
486
|
+
UNCOVERED: { fill: '#3d1515', border: '#f87171', glow: 'rgba(239, 68, 68, 0.45)' },
|
|
487
|
+
STALE: { fill: '#3d2c0d', border: '#fbbf24', glow: 'rgba(245, 158, 11, 0.45)' },
|
|
488
|
+
COVERED: { fill: '#0d3320', border: '#34d399', glow: 'rgba(16, 185, 129, 0.4)' },
|
|
489
|
+
ATRISK: { fill: '#2a1e0a', border: '#fb923c', glow: 'rgba(251, 146, 60, 0.45)' },
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
function covEscape(s) {
|
|
493
|
+
return String(s).replace(
|
|
494
|
+
/[&<>"']/g,
|
|
495
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function covTile(a, opts) {
|
|
500
|
+
const theme = COV_STATUS_THEME[opts.themeKey || a.status] || COV_STATUS_THEME.COVERED;
|
|
501
|
+
const heat = opts.heat;
|
|
502
|
+
const pulse = opts.pulse ? ' data-pulse="true"' : '';
|
|
503
|
+
const id = covEscape(a.id);
|
|
504
|
+
return (
|
|
505
|
+
`<article class="cov-tile" data-area-id="${id}" data-status="${a.status}"${pulse} ` +
|
|
506
|
+
`style="--fill:${theme.fill};--border:${theme.border};--glow:${theme.glow};--heat:${heat}" ` +
|
|
507
|
+
`tabindex="0" role="button" aria-label="${id} — ${a.status.toLowerCase()}, risk ${a.risk}">` +
|
|
508
|
+
`<header class="cov-tile-head"><span class="cov-tile-status">${opts.statusLabel || a.status.toLowerCase()}</span>` +
|
|
509
|
+
`<span class="cov-tile-risk" title="risk score">${a.risk}</span></header>` +
|
|
510
|
+
`<h4 class="cov-tile-name">${id}</h4>` +
|
|
511
|
+
`<dl class="cov-tile-meta">` +
|
|
512
|
+
`<div><dt>tickets</dt><dd>${a.breakdown.recentTickets}</dd></div>` +
|
|
513
|
+
`<div><dt>bugs</dt><dd>${a.breakdown.recentBugs}</dd></div>` +
|
|
514
|
+
`</dl></article>`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function covSection(opts) {
|
|
519
|
+
const tilesHtml = opts.tiles.join('');
|
|
520
|
+
const head =
|
|
521
|
+
`<header class="cov-section-head"><span class="cov-section-icon ${opts.iconClass}"></span>` +
|
|
522
|
+
`<h3 class="cov-section-title">${opts.title}</h3>` +
|
|
523
|
+
`<span class="cov-section-count">${opts.count}</span>` +
|
|
524
|
+
`<span class="cov-section-desc">${opts.desc}</span></header>`;
|
|
525
|
+
if (opts.collapsed) {
|
|
526
|
+
return `<details class="cov-section cov-section-collapsible"><summary>${head}</summary><div class="cov-grid">${tilesHtml}</div></details>`;
|
|
527
|
+
}
|
|
528
|
+
return `<section class="cov-section">${head}<div class="cov-grid">${tilesHtml}</div></section>`;
|
|
529
|
+
}
|
|
530
|
+
|
|
379
531
|
function renderCoverageMap() {
|
|
380
532
|
const cov = window.__COVERAGE__;
|
|
381
|
-
if (!cov
|
|
533
|
+
if (!cov) return;
|
|
382
534
|
const canvas = document.getElementById('coverage-map-canvas');
|
|
383
535
|
if (!canvas) return;
|
|
536
|
+
canvas.innerHTML = '';
|
|
384
537
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
const NEUTRAL = { background: '#e5e7eb', border: '#9ca3af' };
|
|
538
|
+
if (!cov.report.areas.length) {
|
|
539
|
+
canvas.innerHTML =
|
|
540
|
+
'<p class="cov-empty">No SUT areas tracked yet — run <code>/xera-fetch</code> on a ticket with acceptance criteria to populate.</p>';
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
391
543
|
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
544
|
+
const areas = cov.report.areas;
|
|
545
|
+
const needs = areas
|
|
546
|
+
.filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE')
|
|
547
|
+
.sort((a, b) => b.risk - a.risk);
|
|
548
|
+
const covered = areas.filter((a) => a.status === 'COVERED').sort((a, b) => b.risk - a.risk);
|
|
549
|
+
// "At risk": top 1/3 of covered by risk (min 1, only if risk > 0)
|
|
550
|
+
const atRiskCount = covered.length ? Math.max(1, Math.ceil(covered.length / 3)) : 0;
|
|
551
|
+
const atRisk = covered.slice(0, atRiskCount).filter((a) => a.risk > 0);
|
|
552
|
+
const healthy = covered.filter((a) => !atRisk.includes(a));
|
|
553
|
+
const topRisk = areas.reduce((m, a) => (a.risk > m.risk ? a : m), areas[0]);
|
|
554
|
+
const maxRisk = Math.max(...areas.map((a) => a.risk), 1);
|
|
555
|
+
const heatFor = (a) => 0.35 + 0.65 * (a.risk / maxRisk);
|
|
556
|
+
|
|
557
|
+
// Summary bar
|
|
558
|
+
const urgent = needs.length ? ' cov-summary-stat-urgent' : '';
|
|
559
|
+
const summary =
|
|
560
|
+
`<div class="cov-summary">` +
|
|
561
|
+
`<div class="cov-summary-stat${urgent}"><span class="cov-summary-num">${needs.length}</span><span class="cov-summary-label">need action</span></div>` +
|
|
562
|
+
`<div class="cov-summary-divider"></div>` +
|
|
563
|
+
`<div class="cov-summary-stat"><span class="cov-summary-num">${atRisk.length}</span><span class="cov-summary-label">at risk</span></div>` +
|
|
564
|
+
`<div class="cov-summary-divider"></div>` +
|
|
565
|
+
`<div class="cov-summary-stat"><span class="cov-summary-num">${healthy.length}</span><span class="cov-summary-label">healthy</span></div>` +
|
|
566
|
+
`<div class="cov-summary-top">` +
|
|
567
|
+
`<span class="cov-summary-top-label">top risk</span>` +
|
|
568
|
+
`<button class="cov-summary-top-btn" data-area-id="${covEscape(topRisk.id)}">${covEscape(topRisk.id)} <span class="cov-summary-top-risk">${topRisk.risk}</span></button>` +
|
|
569
|
+
`</div>` +
|
|
570
|
+
`</div>`;
|
|
571
|
+
|
|
572
|
+
let html = summary;
|
|
573
|
+
|
|
574
|
+
if (needs.length) {
|
|
575
|
+
const tiles = needs.map((a, idx) =>
|
|
576
|
+
covTile(a, { heat: heatFor(a), pulse: idx === 0 && a.risk > 0 }),
|
|
577
|
+
);
|
|
578
|
+
html += covSection({
|
|
579
|
+
title: 'Needs attention',
|
|
580
|
+
desc: 'Write new tests or refresh stale ones',
|
|
581
|
+
iconClass: 'cov-section-icon-urgent',
|
|
582
|
+
count: needs.length,
|
|
583
|
+
tiles,
|
|
584
|
+
});
|
|
395
585
|
}
|
|
396
586
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
587
|
+
if (atRisk.length) {
|
|
588
|
+
const tiles = atRisk.map((a) =>
|
|
589
|
+
covTile(a, {
|
|
590
|
+
themeKey: 'ATRISK',
|
|
591
|
+
statusLabel: 'at risk',
|
|
592
|
+
heat: heatFor(a),
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
html += covSection({
|
|
596
|
+
title: 'At risk',
|
|
597
|
+
desc: 'Covered, but recently changed — re-run scenarios after each merge',
|
|
598
|
+
iconClass: 'cov-section-icon-warn',
|
|
599
|
+
count: atRisk.length,
|
|
600
|
+
tiles,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (healthy.length) {
|
|
605
|
+
const tiles = healthy.map((a) => covTile(a, { heat: heatFor(a) }));
|
|
606
|
+
html += covSection({
|
|
607
|
+
title: 'Healthy',
|
|
608
|
+
desc: 'Low recent activity, well-covered',
|
|
609
|
+
iconClass: 'cov-section-icon-ok',
|
|
610
|
+
count: healthy.length,
|
|
611
|
+
tiles,
|
|
612
|
+
collapsed: needs.length > 0 || atRisk.length > 0, // collapse if there's anything actionable above
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
canvas.innerHTML = html;
|
|
617
|
+
attachCovHandlers();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Coverage drawer ──────────────────────────────────────
|
|
621
|
+
function attachCovHandlers() {
|
|
622
|
+
document.querySelectorAll('.cov-tile').forEach((t) => {
|
|
623
|
+
t.addEventListener('click', () => openCovDrawer(t.dataset.areaId));
|
|
624
|
+
t.addEventListener('keydown', (e) => {
|
|
625
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
626
|
+
e.preventDefault();
|
|
627
|
+
openCovDrawer(t.dataset.areaId);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
document.querySelectorAll('.cov-summary-top-btn').forEach((b) => {
|
|
632
|
+
b.addEventListener('click', () => openCovDrawer(b.dataset.areaId));
|
|
403
633
|
});
|
|
634
|
+
const closeBtn = document.getElementById('cov-drawer-close');
|
|
635
|
+
if (closeBtn && !closeBtn.dataset.bound) {
|
|
636
|
+
closeBtn.dataset.bound = '1';
|
|
637
|
+
closeBtn.addEventListener('click', closeCovDrawer);
|
|
638
|
+
document.addEventListener('keydown', (e) => {
|
|
639
|
+
if (e.key === 'Escape') closeCovDrawer();
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
404
643
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
644
|
+
function openCovDrawer(areaId) {
|
|
645
|
+
const cov = window.__COVERAGE__;
|
|
646
|
+
const graph = window.__GRAPH__;
|
|
647
|
+
if (!cov || !graph) return;
|
|
648
|
+
const area = cov.report.areas.find((a) => a.id === areaId);
|
|
649
|
+
if (!area) return;
|
|
650
|
+
|
|
651
|
+
const drawer = document.getElementById('cov-drawer');
|
|
652
|
+
const status = document.getElementById('cov-drawer-status');
|
|
653
|
+
const title = document.getElementById('cov-drawer-title');
|
|
654
|
+
const body = document.getElementById('cov-drawer-body');
|
|
655
|
+
if (!drawer || !status || !title || !body) return;
|
|
656
|
+
|
|
657
|
+
const theme = COV_STATUS_THEME[area.status] || COV_STATUS_THEME.COVERED;
|
|
658
|
+
status.textContent = area.status.toLowerCase();
|
|
659
|
+
status.style.color = theme.border;
|
|
660
|
+
status.style.background = `${theme.fill}`;
|
|
661
|
+
status.style.borderColor = theme.border;
|
|
662
|
+
title.textContent = area.id;
|
|
663
|
+
|
|
664
|
+
// Find connected nodes (1-hop) from graph
|
|
665
|
+
const connected = new Set();
|
|
666
|
+
for (const e of graph.edges) {
|
|
667
|
+
if (e.from === areaId) connected.add(e.to);
|
|
668
|
+
if (e.to === areaId) connected.add(e.from);
|
|
669
|
+
}
|
|
670
|
+
const nodesById = {};
|
|
671
|
+
for (const n of graph.nodes) nodesById[n.id] = n;
|
|
672
|
+
const connectedTickets = [...connected].filter((id) => nodesById[id]?.group === 'Ticket');
|
|
673
|
+
const connectedScenarios = [...connected].filter((id) => nodesById[id]?.group === 'Scenario');
|
|
674
|
+
|
|
675
|
+
const passCount = connectedScenarios.filter((id) => nodesById[id]?.color !== '#EF4444').length;
|
|
676
|
+
const failCount = connectedScenarios.length - passCount;
|
|
677
|
+
|
|
678
|
+
// AC gaps among connected tickets
|
|
679
|
+
const acGaps = (cov.report.tickets || []).filter(
|
|
680
|
+
(t) => connectedTickets.includes(t.id) && t.unsatisfiedAcs?.length,
|
|
412
681
|
);
|
|
682
|
+
|
|
683
|
+
const riskBreakdown = `
|
|
684
|
+
<section class="cov-drawer-section">
|
|
685
|
+
<h4>Risk breakdown</h4>
|
|
686
|
+
<div class="cov-drawer-risk">
|
|
687
|
+
<div class="cov-drawer-risk-num">${area.risk}</div>
|
|
688
|
+
<ul class="cov-drawer-risk-meta">
|
|
689
|
+
<li><span>${area.breakdown.recentTickets}</span> recent tickets</li>
|
|
690
|
+
<li><span>${area.breakdown.recentBugs}</span> recent bugs</li>
|
|
691
|
+
${area.breakdown.criticalBoost ? '<li class="cov-drawer-risk-crit">⚠ critical area</li>' : ''}
|
|
692
|
+
</ul>
|
|
693
|
+
</div>
|
|
694
|
+
</section>`;
|
|
695
|
+
|
|
696
|
+
const scenariosSection = connectedScenarios.length
|
|
697
|
+
? `<section class="cov-drawer-section">
|
|
698
|
+
<h4>Scenarios <span class="cov-drawer-count">${connectedScenarios.length}</span></h4>
|
|
699
|
+
<div class="cov-drawer-pillrow">
|
|
700
|
+
${passCount ? `<span class="cov-drawer-pill cov-pill-pass">${passCount} passing</span>` : ''}
|
|
701
|
+
${failCount ? `<span class="cov-drawer-pill cov-pill-fail">${failCount} failing</span>` : ''}
|
|
702
|
+
</div>
|
|
703
|
+
<ul class="cov-drawer-list">
|
|
704
|
+
${connectedScenarios
|
|
705
|
+
.map((id) => {
|
|
706
|
+
const n = nodesById[id];
|
|
707
|
+
const fail = n?.color === '#EF4444';
|
|
708
|
+
return `<li><button class="cov-drawer-item" data-focus-id="${covEscape(id)}"><i class="cov-dot ${fail ? 'cov-dot-fail' : 'cov-dot-pass'}"></i><span>${covEscape(n?.label || id)}</span></button></li>`;
|
|
709
|
+
})
|
|
710
|
+
.join('')}
|
|
711
|
+
</ul>
|
|
712
|
+
</section>`
|
|
713
|
+
: '';
|
|
714
|
+
|
|
715
|
+
const ticketsSection = connectedTickets.length
|
|
716
|
+
? `<section class="cov-drawer-section">
|
|
717
|
+
<h4>Tickets touching this area <span class="cov-drawer-count">${connectedTickets.length}</span></h4>
|
|
718
|
+
<ul class="cov-drawer-list">
|
|
719
|
+
${connectedTickets
|
|
720
|
+
.map((id) => {
|
|
721
|
+
const n = nodesById[id];
|
|
722
|
+
return `<li><button class="cov-drawer-item" data-focus-id="${covEscape(id)}"><i class="cov-dot cov-dot-ticket"></i><span>${covEscape(id)}${n?.title ? ` — ${covEscape(n.title.replace(/^[A-Z]+-\d+\s*[—–-]\s*/, ''))}` : ''}</span></button></li>`;
|
|
723
|
+
})
|
|
724
|
+
.join('')}
|
|
725
|
+
</ul>
|
|
726
|
+
</section>`
|
|
727
|
+
: '';
|
|
728
|
+
|
|
729
|
+
const acSection = acGaps.length
|
|
730
|
+
? `<section class="cov-drawer-section">
|
|
731
|
+
<h4>AC gaps <span class="cov-drawer-count">${acGaps.reduce((s, t) => s + t.unsatisfiedAcs.length, 0)}</span></h4>
|
|
732
|
+
<ul class="cov-drawer-list cov-drawer-list-stack">
|
|
733
|
+
${acGaps
|
|
734
|
+
.map(
|
|
735
|
+
(t) =>
|
|
736
|
+
`<li><div class="cov-drawer-ac"><strong>${covEscape(t.id)}</strong> — ${t.satisfiedCount}/${t.acCount} covered<div class="cov-drawer-ac-tags">${t.unsatisfiedAcs.map((ac) => `<span class="cov-drawer-ac-tag">AC-${ac.index}</span>`).join('')}</div></div></li>`,
|
|
737
|
+
)
|
|
738
|
+
.join('')}
|
|
739
|
+
</ul>
|
|
740
|
+
</section>`
|
|
741
|
+
: '';
|
|
742
|
+
|
|
743
|
+
const actions = `
|
|
744
|
+
<section class="cov-drawer-actions">
|
|
745
|
+
<button class="cov-drawer-action" data-focus-id="${covEscape(area.id)}">
|
|
746
|
+
<span>View in graph</span>
|
|
747
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>
|
|
748
|
+
</button>
|
|
749
|
+
</section>`;
|
|
750
|
+
|
|
751
|
+
body.innerHTML = riskBreakdown + scenariosSection + ticketsSection + acSection + actions;
|
|
752
|
+
|
|
753
|
+
// Wire focus buttons → switch to Knowledge tab and select node
|
|
754
|
+
body.querySelectorAll('[data-focus-id]').forEach((b) => {
|
|
755
|
+
b.addEventListener('click', () => {
|
|
756
|
+
const id = b.dataset.focusId;
|
|
757
|
+
closeCovDrawer();
|
|
758
|
+
const knowledgeBtn = document.querySelector('.toplevel-tabs button[data-tab="knowledge"]');
|
|
759
|
+
if (knowledgeBtn && !knowledgeBtn.classList.contains('active')) knowledgeBtn.click();
|
|
760
|
+
requestAnimationFrame(() => {
|
|
761
|
+
if (typeof window.__xeraFocus === 'function') window.__xeraFocus(id);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
drawer.classList.remove('hidden');
|
|
767
|
+
drawer.setAttribute('aria-hidden', 'false');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function closeCovDrawer() {
|
|
771
|
+
const drawer = document.getElementById('cov-drawer');
|
|
772
|
+
if (!drawer) return;
|
|
773
|
+
drawer.classList.add('hidden');
|
|
774
|
+
drawer.setAttribute('aria-hidden', 'true');
|
|
413
775
|
}
|
|
414
776
|
|
|
415
777
|
// Task 28 — coverage list: sortable area + AC gap tables
|
|
@@ -484,20 +846,78 @@ function renderCoverageTrend() {
|
|
|
484
846
|
const n = snap.areas.filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE').length;
|
|
485
847
|
return { day: d, value: n };
|
|
486
848
|
});
|
|
849
|
+
|
|
850
|
+
// Single data point — render a quiet placeholder rather than a degenerate chart
|
|
851
|
+
if (points.length === 1) {
|
|
852
|
+
container.innerHTML =
|
|
853
|
+
`<div class="cov-trend-single">` +
|
|
854
|
+
`<span class="cov-trend-value">${points[0].value}</span>` +
|
|
855
|
+
`<span class="cov-trend-unit">uncovered + stale areas</span>` +
|
|
856
|
+
`<span class="cov-trend-date">${points[0].day}</span>` +
|
|
857
|
+
`<p class="cov-trend-hint">Run /xera-coverage on subsequent days to build a trend line.</p>` +
|
|
858
|
+
`</div>`;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
487
862
|
const W = 800;
|
|
488
|
-
const H =
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
863
|
+
const H = 260;
|
|
864
|
+
const PAD_L = 40;
|
|
865
|
+
const PAD_R = 24;
|
|
866
|
+
const PAD_T = 16;
|
|
867
|
+
const PAD_B = 32;
|
|
868
|
+
const innerW = W - PAD_L - PAD_R;
|
|
869
|
+
const innerH = H - PAD_T - PAD_B;
|
|
870
|
+
const rawMax = Math.max(...points.map((p) => p.value), 1);
|
|
871
|
+
// Round maxValue up to a "nice" integer so y-axis labels are clean integers
|
|
872
|
+
const niceMax = rawMax <= 4 ? rawMax : Math.ceil(rawMax / 5) * 5;
|
|
873
|
+
const stepX = innerW / (points.length - 1);
|
|
874
|
+
const xy = points.map((p, idx) => ({
|
|
875
|
+
x: PAD_L + idx * stepX,
|
|
876
|
+
y: PAD_T + innerH - (p.value / niceMax) * innerH,
|
|
877
|
+
v: p.value,
|
|
878
|
+
d: p.day,
|
|
879
|
+
}));
|
|
880
|
+
const linePath = xy.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
881
|
+
const areaPath = `${linePath} L${xy[xy.length - 1].x},${PAD_T + innerH} L${xy[0].x},${PAD_T + innerH} Z`;
|
|
882
|
+
|
|
883
|
+
// Horizontal grid lines — pick step that yields integer labels
|
|
884
|
+
const tickCount = Math.min(niceMax, 4);
|
|
885
|
+
const tickStep = niceMax / tickCount;
|
|
886
|
+
const seenTicks = new Set();
|
|
887
|
+
const grid = [];
|
|
888
|
+
for (let i = 1; i <= tickCount; i++) {
|
|
889
|
+
const v = Math.round(tickStep * i);
|
|
890
|
+
if (seenTicks.has(v)) continue;
|
|
891
|
+
seenTicks.add(v);
|
|
892
|
+
const y = PAD_T + innerH - (v / niceMax) * innerH;
|
|
893
|
+
grid.push(
|
|
894
|
+
`<line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="#1a2540" stroke-width="1" stroke-dasharray="2 4"/>` +
|
|
895
|
+
`<text x="${PAD_L - 8}" y="${y + 3}" font-size="10" text-anchor="end">${v}</text>`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
// Baseline 0 tick
|
|
899
|
+
grid.push(
|
|
900
|
+
`<line x1="${PAD_L}" y1="${PAD_T + innerH}" x2="${W - PAD_R}" y2="${PAD_T + innerH}" stroke="#1e2d45" stroke-width="1"/>` +
|
|
901
|
+
`<text x="${PAD_L - 8}" y="${PAD_T + innerH + 3}" font-size="10" text-anchor="end">0</text>`,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const dots = xy
|
|
905
|
+
.map(
|
|
906
|
+
(p) =>
|
|
907
|
+
`<circle cx="${p.x}" cy="${p.y}" r="3" fill="#ef4444" stroke="#080c14" stroke-width="1.5"><title>${p.d}: ${p.v} uncovered/stale</title></circle>`,
|
|
908
|
+
)
|
|
909
|
+
.join('');
|
|
499
910
|
|
|
500
911
|
const labelFirst = points[0].day;
|
|
501
912
|
const labelLast = points[points.length - 1].day;
|
|
502
|
-
container.innerHTML =
|
|
913
|
+
container.innerHTML =
|
|
914
|
+
`<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">` +
|
|
915
|
+
`<defs><linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#ef4444" stop-opacity="0.25"/><stop offset="100%" stop-color="#ef4444" stop-opacity="0"/></linearGradient></defs>` +
|
|
916
|
+
grid.join('') +
|
|
917
|
+
`<path d="${areaPath}" fill="url(#trendFill)" stroke="none"/>` +
|
|
918
|
+
`<path d="${linePath}" fill="none" stroke="#ef4444" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
|
|
919
|
+
dots +
|
|
920
|
+
`<text x="${PAD_L}" y="${H - 8}" font-size="10">${labelFirst}</text>` +
|
|
921
|
+
`<text x="${W - PAD_R}" y="${H - 8}" font-size="10" text-anchor="end">${labelLast}</text>` +
|
|
922
|
+
`</svg>`;
|
|
503
923
|
}
|