@xera-ai/core 0.9.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/bin/internal.js +1415 -534
  2. package/dist/bin/templates/LICENSE-vis-network.txt +2 -0
  3. package/dist/bin/templates/coverage-panel.html.fragment +20 -0
  4. package/dist/bin/templates/graph.css +379 -43
  5. package/dist/bin/templates/graph.html.template +35 -15
  6. package/dist/bin/templates/graph.js +458 -56
  7. package/dist/bin/templates/vis-network.min.js +3 -24976
  8. package/dist/src/index.js +6 -0
  9. package/package.json +3 -3
  10. package/src/bin-internal/ac-coverage-backfill-finalize.ts +90 -0
  11. package/src/bin-internal/ac-coverage-backfill-prepare.ts +72 -0
  12. package/src/bin-internal/coverage-prepare.ts +123 -0
  13. package/src/bin-internal/fill-gap-finalize.ts +115 -0
  14. package/src/bin-internal/fill-gap-prepare.ts +150 -0
  15. package/src/bin-internal/graph-render.ts +32 -4
  16. package/src/bin-internal/index.ts +10 -0
  17. package/src/bin-internal/verify-prompts.ts +2 -0
  18. package/src/config/schema.ts +9 -0
  19. package/src/coverage/index.ts +29 -0
  20. package/src/coverage/report.ts +206 -0
  21. package/src/coverage/risk.ts +69 -0
  22. package/src/coverage/status.ts +76 -0
  23. package/src/coverage/types.ts +11 -0
  24. package/src/coverage/why.ts +122 -0
  25. package/src/graph/render.ts +16 -2
  26. package/src/graph/schema.ts +54 -1
  27. package/src/graph/store.ts +96 -6
  28. package/src/graph/templates/LICENSE-vis-network.txt +2 -0
  29. package/src/graph/templates/coverage-panel.html.fragment +20 -0
  30. package/src/graph/templates/graph.css +379 -43
  31. package/src/graph/templates/graph.html.template +35 -15
  32. package/src/graph/templates/graph.js +458 -56
  33. package/src/graph/templates/vis-network.min.js +3 -24976
  34. package/src/graph/types.ts +56 -1
@@ -1,101 +1,503 @@
1
1
  (() => {
2
- var data = window.__GRAPH__ || { nodes: [], edges: [] };
2
+ var data = window.__GRAPH__ || { nodes: [], edges: [], stats: {} };
3
3
  var container = document.getElementById('canvas');
4
4
  if (!container || typeof vis === 'undefined') {
5
- document.body.innerHTML =
6
- '<p style="padding:20px;color:#6B7280">Failed to load vis-network. Check console.</p>';
5
+ container.innerHTML =
6
+ '<p style="padding:40px;color:#4b5563;font-size:13px">Failed to load vis-network.</p>';
7
7
  return;
8
8
  }
9
9
 
10
- var nodes = new vis.DataSet(data.nodes);
11
- var edges = new vis.DataSet(data.edges);
10
+ // ── Populate stats chips ─────────────────────────────
11
+ var statsBar = document.getElementById('stats-bar');
12
+ var s = data.stats || {};
13
+ var chips = [
14
+ { label: `${s.tickets ?? 0} tickets`, color: '#3b82f6' },
15
+ { label: `${s.scenarios ?? 0} scenarios`, color: '#3fb950' },
16
+ { label: `${s.poms ?? 0} POMs`, color: '#e3b341' },
17
+ { label: `${s.edges ?? 0} edges`, color: '#475569' },
18
+ ];
19
+ if (s.failures) chips.splice(2, 0, { label: `${s.failures} failures`, color: '#f85149' });
20
+ chips.forEach((c) => {
21
+ var el = document.createElement('div');
22
+ el.className = 'stat-chip';
23
+ el.innerHTML = `<span class="dot" style="background:${c.color}"></span>${c.label}`;
24
+ statsBar.appendChild(el);
25
+ });
26
+
27
+ // ── Active filter labels ─────────────────────────────
28
+ ['pass', 'fail', 'p0'].forEach((key) => {
29
+ var label = document.getElementById(`label-${key}`);
30
+ var cb = document.getElementById(`filter-${key}`);
31
+ if (cb.checked) label.classList.add('active');
32
+ cb.addEventListener('change', () => {
33
+ label.classList.toggle('active', cb.checked);
34
+ });
35
+ });
36
+
37
+ // ── Node visual enhancement ──────────────────────────
38
+ function trunc(str, n) {
39
+ return str && str.length > n ? `${str.slice(0, n - 1)}…` : str;
40
+ }
41
+
42
+ var NODE_STYLES = {
43
+ Ticket: {
44
+ shape: 'dot',
45
+ sizeBase: 20,
46
+ color: {
47
+ background: '#112240',
48
+ border: '#4d94ff',
49
+ highlight: { background: '#1a3a6e', border: '#82b4ff' },
50
+ hover: { background: '#152d5a', border: '#5fa3ff' },
51
+ },
52
+ font: { size: 12, color: '#93c5fd', strokeWidth: 3, strokeColor: '#050810', bold: true },
53
+ borderWidth: 2,
54
+ },
55
+ Scenario: {
56
+ shape: 'box',
57
+ sizeBase: 10,
58
+ color: {
59
+ background: '#0a2218',
60
+ border: '#2ea44f',
61
+ highlight: { background: '#0d3320', border: '#3fb950' },
62
+ hover: { background: '#0d2a1a', border: '#3fb950' },
63
+ },
64
+ font: { size: 10, color: '#86efac', strokeWidth: 2, strokeColor: '#050810' },
65
+ borderWidth: 1.5,
66
+ },
67
+ 'Scenario-fail': {
68
+ shape: 'box',
69
+ sizeBase: 10,
70
+ color: {
71
+ background: '#2d1214',
72
+ border: '#cf3939',
73
+ highlight: { background: '#3d1515', border: '#f85149' },
74
+ hover: { background: '#3a1416', border: '#f85149' },
75
+ },
76
+ font: { size: 10, color: '#fca5a5', strokeWidth: 2, strokeColor: '#050810' },
77
+ borderWidth: 1.5,
78
+ },
79
+ POM: {
80
+ shape: 'diamond',
81
+ sizeBase: 16,
82
+ color: {
83
+ background: '#2a1e0a',
84
+ border: '#c99a20',
85
+ highlight: { background: '#3d2c0d', border: '#e3b341' },
86
+ hover: { background: '#332410', border: '#e3b341' },
87
+ },
88
+ font: { size: 11, color: '#fde68a', strokeWidth: 2, strokeColor: '#050810' },
89
+ borderWidth: 2,
90
+ },
91
+ Area: {
92
+ shape: 'hexagon',
93
+ sizeBase: 14,
94
+ color: {
95
+ background: '#1a2035',
96
+ border: '#4b5563',
97
+ highlight: { background: '#232d47', border: '#6b7280' },
98
+ hover: { background: '#1e2840', border: '#6b7280' },
99
+ },
100
+ font: { size: 10, color: '#9ca3af', strokeWidth: 2, strokeColor: '#050810' },
101
+ borderWidth: 1.5,
102
+ },
103
+ };
104
+
105
+ var nodeData = data.nodes.map((n) => {
106
+ var styleKey = n.group === 'Scenario' && n.color === '#EF4444' ? 'Scenario-fail' : n.group;
107
+ var style = NODE_STYLES[styleKey] || {};
108
+ var mapped = Object.assign({}, n, {
109
+ label: trunc(n.label, 30),
110
+ shape: style.shape ?? n.shape,
111
+ size: (style.sizeBase ?? 12) + (n.size ?? 12) * 0.4,
112
+ color: style.color ?? n.color,
113
+ font: style.font,
114
+ borderWidth: style.borderWidth ?? 1.5,
115
+ shadow: { enabled: true, color: 'rgba(0,0,0,.6)', size: 10, x: 0, y: 3 },
116
+ margin: n.group === 'Scenario' ? 6 : undefined,
117
+ });
118
+ delete mapped.group;
119
+ return mapped;
120
+ });
121
+
122
+ // ── Edge enhancement ─────────────────────────────────
123
+ var edgeData = data.edges.map((e) => {
124
+ var isTests = e.label === 'tests';
125
+ return Object.assign({}, e, {
126
+ color: {
127
+ color: isTests ? '#1e3a6e' : '#1e2d3d',
128
+ highlight: isTests ? '#3b82f6' : '#60a5fa',
129
+ hover: isTests ? '#2563eb' : '#60a5fa',
130
+ opacity: 0.8,
131
+ },
132
+ width: isTests ? 1.5 : 1,
133
+ dashes: !isTests,
134
+ font: {
135
+ size: 8,
136
+ color: '#2d3f5f',
137
+ strokeWidth: 0,
138
+ align: 'middle',
139
+ },
140
+ });
141
+ });
142
+
143
+ var nodes = new vis.DataSet(nodeData);
144
+ var edges = new vis.DataSet(edgeData);
145
+
146
+ // ── Network init ─────────────────────────────────────
12
147
  var network = new vis.Network(
13
148
  container,
14
- { nodes: nodes, edges: edges },
149
+ { nodes, edges },
15
150
  {
16
- physics: { stabilization: { iterations: 200 } },
17
- interaction: { hover: true, navigationButtons: true, keyboard: true },
151
+ physics: {
152
+ barnesHut: {
153
+ gravitationalConstant: -10000,
154
+ centralGravity: 0.15,
155
+ springLength: 160,
156
+ springConstant: 0.025,
157
+ damping: 0.18,
158
+ avoidOverlap: 0.5,
159
+ },
160
+ stabilization: { iterations: 400, updateInterval: 20 },
161
+ },
162
+ interaction: {
163
+ hover: true,
164
+ navigationButtons: false,
165
+ keyboard: { enabled: true, speed: { x: 10, y: 10, zoom: 0.05 } },
166
+ tooltipDelay: 200,
167
+ zoomSpeed: 0.8,
168
+ },
18
169
  edges: {
19
- smooth: { type: 'continuous', forceDirection: 'none', roundness: 0.4 },
20
- font: { size: 9, color: '#6B7280' },
170
+ smooth: { type: 'curvedCW', forceDirection: 'none', roundness: 0.2 },
171
+ arrows: { to: { enabled: true, scaleFactor: 0.5, type: 'arrow' } },
172
+ selectionWidth: 2,
173
+ },
174
+ nodes: {
175
+ chosen: true,
21
176
  },
22
- nodes: { font: { size: 11, color: '#1F2937' } },
23
177
  },
24
178
  );
25
179
 
180
+ // ── Progress bar ─────────────────────────────────────
181
+ var progressBar = document.getElementById('progress-bar');
182
+ network.on('stabilizationProgress', (p) => {
183
+ progressBar.style.width = `${Math.round((p.iterations / p.total) * 100)}%`;
184
+ });
185
+ network.once('stabilizationIterationsDone', () => {
186
+ progressBar.style.width = '100%';
187
+ setTimeout(() => {
188
+ progressBar.style.transition = 'opacity .4s';
189
+ progressBar.style.opacity = '0';
190
+ }, 300);
191
+ network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
192
+ });
193
+
194
+ // ── Side panel ───────────────────────────────────────
26
195
  var sidepanel = document.getElementById('sidepanel');
27
196
  var spTitle = document.getElementById('sp-title');
197
+ var spGroup = document.getElementById('sp-group');
28
198
  var spDesc = document.getElementById('sp-desc');
29
199
  var spActions = document.getElementById('sp-actions');
30
200
 
31
- network.on('click', (params) => {
32
- if (params.nodes.length === 0) {
33
- sidepanel.classList.add('hidden');
34
- return;
35
- }
36
- var nodeId = params.nodes[0];
37
- var node = nodes.get(nodeId);
38
- spTitle.textContent = node.label;
39
- spDesc.textContent = node.title || '';
201
+ function showPanel(nodeId) {
202
+ var orig = data.nodes.find((n) => n.id === nodeId);
203
+ if (!orig) return;
204
+
205
+ var isFail = orig.group === 'Scenario' && orig.color === '#EF4444';
206
+ var badgeClass =
207
+ orig.group === 'Ticket'
208
+ ? 'ticket'
209
+ : orig.group === 'POM'
210
+ ? 'pom'
211
+ : isFail
212
+ ? 'scenario-fail'
213
+ : 'scenario';
214
+
215
+ spGroup.className = `sp-group-badge ${badgeClass}`;
216
+ spGroup.textContent = isFail ? 'Scenario · fail' : orig.group;
217
+ spTitle.textContent = orig.title
218
+ ? orig.title.replace(/^[A-Z]+-\d+\s*[—–-]\s*/, '')
219
+ : orig.label;
220
+ spDesc.textContent = orig.title || '';
40
221
  spActions.innerHTML = '';
41
222
  var btn = null;
42
- if (node.group === 'Ticket') {
223
+
224
+ if (orig.group === 'Ticket') {
43
225
  btn = document.createElement('button');
44
- btn.textContent = 'Copy /xera-impact command';
226
+ btn.textContent = `Copy /xera-impact ${nodeId}`;
45
227
  btn.onclick = () => {
46
228
  navigator.clipboard?.writeText(`/xera-impact ${nodeId}`);
229
+ btn.textContent = '✓ Copied!';
230
+ btn.classList.add('copied');
231
+ setTimeout(() => {
232
+ btn.textContent = `Copy /xera-impact ${nodeId}`;
233
+ btn.classList.remove('copied');
234
+ }, 1800);
47
235
  };
48
236
  spActions.appendChild(btn);
49
237
  }
238
+
50
239
  sidepanel.classList.remove('hidden');
240
+ }
51
241
 
52
- // Highlight ego-graph (depth 2)
53
- var connected = network.getConnectedNodes(nodeId);
54
- var connected2 = [];
55
- connected.forEach((id) => {
56
- Array.prototype.push.apply(connected2, network.getConnectedNodes(id));
57
- });
58
- var keep = new Set([nodeId].concat(connected).concat(connected2));
59
- nodes.forEach((n) => {
60
- var update = { id: n.id, opacity: keep.has(n.id) ? 1 : 0.2 };
61
- nodes.update(update);
62
- });
63
- });
242
+ function dimOthers(nodeId) {
243
+ var hop1 = new Set(network.getConnectedNodes(nodeId));
244
+ var hop2 = new Set();
245
+ for (const id of hop1) {
246
+ for (const x of network.getConnectedNodes(id)) {
247
+ hop2.add(x);
248
+ }
249
+ }
250
+ var keep = new Set([nodeId, ...hop1, ...hop2]);
251
+ for (const n of nodes.get()) {
252
+ nodes.update({ id: n.id, opacity: keep.has(n.id) ? 1 : 0.1 });
253
+ }
254
+ for (const e of edges.get()) {
255
+ const visible = keep.has(e.from) && keep.has(e.to);
256
+ edges.update({
257
+ id: e.id,
258
+ color: Object.assign({}, e.color, { opacity: visible ? 0.8 : 0.06 }),
259
+ });
260
+ }
261
+ }
64
262
 
65
- document.getElementById('reset').onclick = () => {
66
- nodes.forEach((n) => {
263
+ function resetView() {
264
+ for (const n of nodes.get()) {
67
265
  nodes.update({ id: n.id, opacity: 1 });
68
- });
266
+ }
267
+ for (const e of edges.get()) {
268
+ edges.update({ id: e.id, color: Object.assign({}, e.color, { opacity: 0.8 }) });
269
+ }
69
270
  sidepanel.classList.add('hidden');
70
- network.fit();
271
+ }
272
+
273
+ network.on('click', (params) => {
274
+ if (params.nodes.length === 0) {
275
+ resetView();
276
+ return;
277
+ }
278
+ showPanel(params.nodes[0]);
279
+ dimOthers(params.nodes[0]);
280
+ });
281
+
282
+ // ── Controls ─────────────────────────────────────────
283
+ document.getElementById('reset-btn').onclick = () => {
284
+ resetView();
285
+ network.fit({ animation: { duration: 350, easingFunction: 'easeInOutQuad' } });
71
286
  };
72
287
 
73
288
  document.getElementById('search').oninput = (e) => {
74
- var q = e.target.value.toLowerCase();
289
+ const q = e.target.value.toLowerCase();
75
290
  if (!q) {
76
- nodes.forEach((n) => {
77
- nodes.update({ id: n.id, opacity: 1 });
78
- });
291
+ resetView();
79
292
  return;
80
293
  }
81
- nodes.forEach((n) => {
82
- var matches =
83
- (n.label || '').toLowerCase().includes(q) || (n.id || '').toLowerCase().includes(q);
84
- nodes.update({ id: n.id, opacity: matches ? 1 : 0.2 });
85
- });
294
+ for (const n of nodes.get()) {
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
+ }
86
302
  };
87
303
 
88
304
  ['filter-pass', 'filter-fail', 'filter-p0'].forEach((id) => {
89
305
  document.getElementById(id).onchange = () => {
90
- var pass = document.getElementById('filter-pass').checked;
91
- var fail = document.getElementById('filter-fail').checked;
92
- nodes.forEach((n) => {
93
- if (n.group !== 'Scenario') return;
94
- var visible = true;
95
- if (n.color === '#10B981' && !pass) visible = false;
96
- if (n.color === '#EF4444' && !fail) visible = false;
97
- nodes.update({ id: n.id, hidden: !visible });
98
- });
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
+ }
99
316
  };
100
317
  });
101
318
  })();
319
+
320
+ // v0.8.1 — top-level tab switching
321
+ (function setupTabs() {
322
+ const tabButtons = document.querySelectorAll('.toplevel-tabs button');
323
+ if (!tabButtons.length) return;
324
+ tabButtons.forEach((btn) => {
325
+ btn.addEventListener('click', () => {
326
+ tabButtons.forEach((b) => {
327
+ b.classList.remove('active');
328
+ });
329
+ btn.classList.add('active');
330
+ const tab = btn.getAttribute('data-tab');
331
+ document.querySelectorAll('[data-tab-panel]').forEach((panel) => {
332
+ if (panel.getAttribute('data-tab-panel') === tab) {
333
+ panel.classList.add('active');
334
+ panel.removeAttribute('hidden');
335
+ } else {
336
+ panel.classList.remove('active');
337
+ }
338
+ });
339
+ if (tab === 'coverage' && window.__COVERAGE__) {
340
+ renderCoverageOnce();
341
+ }
342
+ });
343
+ });
344
+ })();
345
+
346
+ // v0.8.1 — coverage subtab switching
347
+ (function setupSubtabs() {
348
+ const subButtons = document.querySelectorAll('.subtabs button');
349
+ subButtons.forEach((btn) => {
350
+ btn.addEventListener('click', () => {
351
+ subButtons.forEach((b) => {
352
+ b.classList.remove('active');
353
+ });
354
+ btn.classList.add('active');
355
+ const sub = btn.getAttribute('data-subtab');
356
+ document.querySelectorAll('[data-subpanel]').forEach((panel) => {
357
+ if (panel.getAttribute('data-subpanel') === sub) {
358
+ panel.removeAttribute('hidden');
359
+ panel.classList.add('active');
360
+ } else {
361
+ panel.setAttribute('hidden', '');
362
+ panel.classList.remove('active');
363
+ }
364
+ });
365
+ });
366
+ });
367
+ })();
368
+
369
+ let _coverageRendered = false;
370
+ function renderCoverageOnce() {
371
+ if (_coverageRendered) return;
372
+ _coverageRendered = true;
373
+ renderCoverageList();
374
+ renderCoverageTrend();
375
+ renderCoverageMap();
376
+ }
377
+
378
+ // Task 27 — coverage map: area color overlay
379
+ function renderCoverageMap() {
380
+ const cov = window.__COVERAGE__;
381
+ if (!cov || !window.__GRAPH__) return;
382
+ const canvas = document.getElementById('coverage-map-canvas');
383
+ if (!canvas) return;
384
+
385
+ const STATUS_COLOR = {
386
+ UNCOVERED: { background: '#fca5a5', border: '#dc2626' },
387
+ STALE: { background: '#fcd34d', border: '#d97706' },
388
+ COVERED: { background: '#86efac', border: '#15803d' },
389
+ };
390
+ const NEUTRAL = { background: '#e5e7eb', border: '#9ca3af' };
391
+
392
+ const areaStatusById = {};
393
+ for (const a of cov.report.areas) {
394
+ areaStatusById[a.id] = a.status;
395
+ }
396
+
397
+ const mappedNodes = window.__GRAPH__.nodes.map((n) => {
398
+ if (n.group === 'SUTArea' && areaStatusById[n.id]) {
399
+ return Object.assign({}, n, { color: STATUS_COLOR[areaStatusById[n.id]] });
400
+ }
401
+ if (n.group !== 'SUTArea') return Object.assign({}, n, { color: NEUTRAL });
402
+ return n;
403
+ });
404
+
405
+ new vis.Network(
406
+ canvas,
407
+ { nodes: new vis.DataSet(mappedNodes), edges: new vis.DataSet(window.__GRAPH__.edges) },
408
+ {
409
+ physics: { enabled: true, stabilization: { iterations: 100 } },
410
+ nodes: { shape: 'dot', font: { size: 11 } },
411
+ },
412
+ );
413
+ }
414
+
415
+ // Task 28 — coverage list: sortable area + AC gap tables
416
+ function renderCoverageList() {
417
+ const cov = window.__COVERAGE__;
418
+ if (!cov) return;
419
+ const listBody = document.querySelector('#coverage-list-table tbody');
420
+ if (listBody) {
421
+ listBody.innerHTML = '';
422
+ for (const a of cov.report.areas) {
423
+ const tr = document.createElement('tr');
424
+ tr.classList.add(`status-${a.status.toLowerCase()}`);
425
+ const cells = [
426
+ a.status,
427
+ a.id,
428
+ String(a.risk),
429
+ String(a.breakdown.recentTickets),
430
+ String(a.breakdown.recentBugs),
431
+ ];
432
+ for (const c of cells) {
433
+ const td = document.createElement('td');
434
+ td.textContent = c;
435
+ tr.appendChild(td);
436
+ }
437
+ listBody.appendChild(tr);
438
+ }
439
+ }
440
+
441
+ const acBody = document.querySelector('#coverage-ac-table tbody');
442
+ if (acBody) {
443
+ acBody.innerHTML = '';
444
+ for (const t of cov.report.tickets) {
445
+ const tr = document.createElement('tr');
446
+ const cells = [
447
+ t.id,
448
+ `${t.satisfiedCount}/${t.acCount}`,
449
+ String(t.gapScore),
450
+ t.unsatisfiedAcs.map((ac) => `AC-${ac.index}`).join(', '),
451
+ ];
452
+ for (const c of cells) {
453
+ const td = document.createElement('td');
454
+ td.textContent = c;
455
+ tr.appendChild(td);
456
+ }
457
+ acBody.appendChild(tr);
458
+ }
459
+ }
460
+ }
461
+
462
+ // Task 29 — coverage trend: inline SVG line chart
463
+ function renderCoverageTrend() {
464
+ const cov = window.__COVERAGE__;
465
+ if (!cov) return;
466
+ const container = document.getElementById('coverage-trend-svg');
467
+ if (!container) return;
468
+
469
+ // Dedup by day (latest snapshot per day wins), sort asc.
470
+ const byDay = {};
471
+ for (const s of cov.snapshots) {
472
+ const day = s.ts.slice(0, 10);
473
+ byDay[day] = s;
474
+ }
475
+ const days = Object.keys(byDay).sort();
476
+ if (days.length === 0) {
477
+ container.innerHTML =
478
+ '<p class="subpanel-hint">No snapshots yet — run /xera-coverage on multiple days to build a trend.</p>';
479
+ return;
480
+ }
481
+
482
+ const points = days.map((d) => {
483
+ const snap = byDay[d];
484
+ const n = snap.areas.filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE').length;
485
+ return { day: d, value: n };
486
+ });
487
+ const W = 800;
488
+ const H = 200;
489
+ const PAD = 30;
490
+ const maxValue = Math.max(...points.map((p) => p.value), 1);
491
+ const stepX = points.length > 1 ? (W - 2 * PAD) / (points.length - 1) : 0;
492
+ const path = points
493
+ .map((p, idx) => {
494
+ const x = PAD + idx * stepX;
495
+ const y = H - PAD - (p.value / maxValue) * (H - 2 * PAD);
496
+ return `${idx === 0 ? 'M' : 'L'}${x},${y}`;
497
+ })
498
+ .join(' ');
499
+
500
+ const labelFirst = points[0].day;
501
+ const labelLast = points[points.length - 1].day;
502
+ container.innerHTML = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"><path d="${path}" fill="none" stroke="#dc2626" stroke-width="2"/><text x="${PAD}" y="${H - 8}" font-size="11" fill="#6b7280">${labelFirst}</text><text x="${W - PAD - 60}" y="${H - 8}" font-size="11" fill="#6b7280">${labelLast}</text><text x="${PAD - 22}" y="${PAD - 4}" font-size="11" fill="#6b7280">${maxValue}</text></svg>`;
503
+ }