trackops 2.0.6 → 2.1.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.
@@ -4,11 +4,12 @@
4
4
  */
5
5
 
6
6
  import { icon } from '../icons.js';
7
- import * as state from '../state.js';
8
- import * as api from '../api.js';
9
- import { esc, formatDate, formatDurationShort, extractHistory, lastDays } from '../utils.js';
10
- import { t } from '../i18n.js';
11
- import { sparkline, lineChart, heatmap } from '../charts.js';
7
+ import * as state from '../state.js';
8
+ import * as api from '../api.js';
9
+ import { esc, formatDate, formatDurationShort, extractHistory, lastDays } from '../utils.js';
10
+ import { t } from '../i18n.js';
11
+ import { sparkline, lineChart, heatmap } from '../charts.js';
12
+ import { flash } from './flash.js';
12
13
 
13
14
  /** Map OPERA phase IDs to standard dev labels */
14
15
  const PHASE_LABELS = {
@@ -26,7 +27,7 @@ function _phaseLabel(phaseId) {
26
27
  }
27
28
 
28
29
  // ─── Module state ────────────────────────────────────────────────
29
- let _activeTab = 'overview'; // 'overview' | 'analytics'
30
+ let _activeTab = 'overview'; // 'overview' | 'analytics' | 'quality'
30
31
  let _activePeriod = '30d'; // for analytics tab
31
32
  let _analytics = null;
32
33
 
@@ -53,10 +54,11 @@ export async function render() {
53
54
  <div class="section-header" style="display:flex;justify-content:space-between;align-items:center">
54
55
  <h2 class="section-title">${t('ui.dashboard.title', {}, 'Dashboard')}</h2>
55
56
  <div class="tab-pills" role="tablist" aria-label="${t('ui.dashboard.tabs', {}, 'Dashboard tabs')}">
56
- <button class="tab-pill${_activeTab === 'overview' ? ' is-active' : ''}" data-tab="overview" role="tab" aria-selected="${_activeTab === 'overview'}" aria-controls="dashboard-content">${t('ui.dashboard.tabOverview', {}, 'Overview')}</button>
57
- <button class="tab-pill${_activeTab === 'analytics' ? ' is-active' : ''}" data-tab="analytics" role="tab" aria-selected="${_activeTab === 'analytics'}" aria-controls="dashboard-content">${t('ui.dashboard.tabAnalytics', {}, 'Analytics')}</button>
58
- </div>
59
- </div>
57
+ <button class="tab-pill${_activeTab === 'overview' ? ' is-active' : ''}" data-tab="overview" role="tab" aria-selected="${_activeTab === 'overview'}" aria-controls="dashboard-content">${t('ui.dashboard.tabOverview', {}, 'Overview')}</button>
58
+ <button class="tab-pill${_activeTab === 'analytics' ? ' is-active' : ''}" data-tab="analytics" role="tab" aria-selected="${_activeTab === 'analytics'}" aria-controls="dashboard-content">${t('ui.dashboard.tabAnalytics', {}, 'Analytics')}</button>
59
+ <button class="tab-pill${_activeTab === 'quality' ? ' is-active' : ''}" data-tab="quality" role="tab" aria-selected="${_activeTab === 'quality'}" aria-controls="dashboard-content">${t('ui.dashboard.tabQuality', {}, 'Quality')}</button>
60
+ </div>
61
+ </div>
60
62
  <div id="dashboard-content" role="tabpanel">
61
63
  ${content}
62
64
  </div>
@@ -100,22 +102,25 @@ function _bindTabSwitching() {
100
102
  });
101
103
  }
102
104
 
103
- function _bindActiveTabEvents() {
104
- if (_activeTab === 'overview') {
105
- _bindTaskActionEvents();
106
- } else {
107
- _bindPeriodEvents();
108
- }
109
- }
105
+ function _bindActiveTabEvents() {
106
+ if (_activeTab === 'overview') {
107
+ _bindTaskActionEvents();
108
+ } else if (_activeTab === 'analytics') {
109
+ _bindPeriodEvents();
110
+ }
111
+ }
110
112
 
111
113
  // ─── Tab content router ──────────────────────────────────────────
112
114
 
113
- async function _renderTabContent(payload) {
114
- if (_activeTab === 'analytics') {
115
- return _renderAnalyticsTab(payload);
116
- }
117
- return _renderOverviewTab(payload);
118
- }
115
+ async function _renderTabContent(payload) {
116
+ if (_activeTab === 'quality') {
117
+ return _renderQualityTab(payload);
118
+ }
119
+ if (_activeTab === 'analytics') {
120
+ return _renderAnalyticsTab(payload);
121
+ }
122
+ return _renderOverviewTab(payload);
123
+ }
119
124
 
120
125
  // ═══════════════════════════════════════════════════════════════════
121
126
  // OVERVIEW TAB
@@ -177,13 +182,18 @@ function _renderOverviewTab(payload) {
177
182
  </div>
178
183
 
179
184
  <!-- Next task -->
180
- <div class="glass-card chart-card card-hover-lift stagger-4" aria-label="${t('ui.overview.nextTask', {}, 'Next task')}">
181
- <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.overview.nextMove', {}, 'Next move')}</p>
182
- ${derived.nextTask ? _renderNextTask(derived.nextTask) : `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noOpenTasks', {}, 'No open tasks')}</p>`}
183
- </div>
184
-
185
- </div>
186
- </div>
185
+ <div class="glass-card chart-card card-hover-lift stagger-4" aria-label="${t('ui.overview.nextTask', {}, 'Next task')}">
186
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.overview.nextMove', {}, 'Next move')}</p>
187
+ ${derived.nextTask ? _renderNextTask(derived.nextTask) : `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noOpenTasks', {}, 'No open tasks')}</p>`}
188
+ </div>
189
+
190
+ <div class="glass-card chart-card card-hover-lift stagger-4" aria-label="${t('ui.overview.agentCoordination', {}, 'Agent coordination')}">
191
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.overview.agentCoordination', {}, 'Agent coordination')}</p>
192
+ ${_renderCoordinationCard(derived, control)}
193
+ </div>
194
+
195
+ </div>
196
+ </div>
187
197
 
188
198
  <!-- (Health grid moved to Analytics tab — no duplication) -->
189
199
  </div>
@@ -356,7 +366,7 @@ function _renderDonut(totals) {
356
366
 
357
367
  // ─────────────────────────────── NEXT TASK ──────────────────────────────────
358
368
 
359
- function _renderNextTask(task) {
369
+ function _renderNextTask(task) {
360
370
  const priorityColors = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
361
371
  const statusLabels = state.getStatusLabels();
362
372
  const statusLabel = statusLabels[task.status] || task.status;
@@ -406,24 +416,171 @@ function _renderNextTask(task) {
406
416
 
407
417
  // ─────────────────────────────── TASK ACTION EVENTS ─────────────────────────
408
418
 
409
- function _bindTaskActionEvents() {
410
- document.querySelectorAll('[data-task-action]').forEach(btn => {
411
- btn.addEventListener('click', () => {
412
- const taskId = btn.dataset.taskId;
413
- const toStatus = btn.dataset.taskToStatus;
414
- const action = btn.dataset.taskAction;
415
- window.dispatchEvent(new CustomEvent('ops:task-action', {
416
- detail: { taskId, toStatus, action },
417
- }));
418
- });
419
- });
420
- }
421
-
422
- // ═══════════════════════════════════════════════════════════════════
423
- // ANALYTICS TAB
424
- // ═══════════════════════════════════════════════════════════════════
425
-
426
- async function _renderAnalyticsTab(payload) {
419
+ function _bindTaskActionEvents() {
420
+ document.querySelectorAll('[data-task-action]').forEach(btn => {
421
+ btn.addEventListener('click', async () => {
422
+ const taskId = btn.dataset.taskId;
423
+ const action = btn.dataset.taskAction;
424
+ if (!taskId || !action) return;
425
+ try {
426
+ await api.taskAction(taskId, action, t('ui.overview.defaultActionNote', { action }, `Change to "${action}" from dashboard overview.`), {
427
+ actor: 'user',
428
+ source: 'dashboard_overview',
429
+ });
430
+ flash(t('ui.overview.actionApplied', {}, 'Task updated.'), 'success');
431
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
432
+ } catch (err) {
433
+ flash(err.message, 'error');
434
+ }
435
+ });
436
+ });
437
+ }
438
+
439
+ function _renderCoordinationCard(derived, control) {
440
+ const inbox = control.meta?.agentInbox?.pending || [];
441
+ const awaitingUser = derived.awaitingUserTasks || [];
442
+ if (!inbox.length && !awaitingUser.length) {
443
+ return `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noCoordination', {}, 'No pending coordination between user and agent.')}</p>`;
444
+ }
445
+
446
+ const lines = [];
447
+ awaitingUser.slice(0, 3).forEach(task => {
448
+ lines.push(`<div class="info-row"><p class="label-sm">${t('ui.overview.awaitingUser', {}, 'Awaiting user')}</p><p class="value">${esc(task.title)}</p></div>`);
449
+ });
450
+ inbox.slice(0, 4).forEach(item => {
451
+ lines.push(`<div class="info-row"><p class="label-sm">${esc(item.kind)}</p><p class="value">${esc(item.message)}</p></div>`);
452
+ });
453
+
454
+ return `<div class="stack stack-sm">${lines.join('')}</div>`;
455
+ }
456
+
457
+ // ═══════════════════════════════════════════════════════════════════
458
+ // ANALYTICS TAB
459
+ // ═══════════════════════════════════════════════════════════════════
460
+
461
+ function _renderQualityTab(payload) {
462
+ const quality = payload.quality || {};
463
+ const report = quality.report || { probes: [], summary: { counts: {} }, verification: { latest: {} } };
464
+ const phaseReadiness = quality.phaseReadiness || { status: 'unknown', blockers: [] };
465
+ const releaseReadiness = quality.releaseReadiness || { status: 'unknown', blockers: [] };
466
+ const promotionReadiness = quality.promotionReadiness || { status: 'unknown', blockers: [] };
467
+ const waivers = quality.waivers || [];
468
+ const failing = (report.probes || []).filter((probe) => probe.status === 'fail');
469
+ const latestRuns = report.verification?.latest || {};
470
+ const domains = Object.entries(report.summary?.byDomain || {});
471
+ const statusLabel = (value) => t(`quality.status.${String(value || 'unknown').toLowerCase()}`, {}, String(value || 'unknown'));
472
+ const blockersLabel = (items) => (items || []).map((blocker) => blocker.id).join(', ') || t('ui.dashboard.quality.noBlockers', {}, 'No blockers');
473
+
474
+ return `
475
+ <div class="view-enter">
476
+ <div class="grid-4" style="margin-bottom:var(--space-5)">
477
+ ${_renderKPI(
478
+ t('ui.dashboard.quality.kpiQuality', {}, 'Quality'),
479
+ statusLabel(report.summary?.overallStatus || 'unknown'),
480
+ t('ui.dashboard.quality.failingProbes', { count: report.summary?.counts?.fail || 0 }, `${report.summary?.counts?.fail || 0} failing probes`),
481
+ 'shield',
482
+ (report.summary?.counts?.fail || 0) ? 'danger' : 'success',
483
+ )}
484
+ ${_renderKPI(
485
+ t('ui.dashboard.quality.kpiPhase', {}, 'Phase readiness'),
486
+ statusLabel(phaseReadiness.status || 'unknown'),
487
+ t('ui.dashboard.quality.blockerCount', { count: phaseReadiness.blockers?.length || 0 }, `${phaseReadiness.blockers?.length || 0} blockers`),
488
+ 'play',
489
+ phaseReadiness.ready ? 'success' : 'warning',
490
+ )}
491
+ ${_renderKPI(
492
+ t('ui.dashboard.quality.kpiRelease', {}, 'Release readiness'),
493
+ statusLabel(releaseReadiness.status || 'unknown'),
494
+ t('ui.dashboard.quality.blockerCount', { count: releaseReadiness.blockers?.length || 0 }, `${releaseReadiness.blockers?.length || 0} blockers`),
495
+ 'checkCircle',
496
+ releaseReadiness.ready ? 'success' : 'danger',
497
+ )}
498
+ ${_renderKPI(
499
+ t('ui.dashboard.quality.kpiPromotion', {}, 'Production promotion'),
500
+ statusLabel(promotionReadiness.status || 'unknown'),
501
+ t('ui.dashboard.quality.blockerCount', { count: promotionReadiness.blockers?.length || 0 }, `${promotionReadiness.blockers?.length || 0} blockers`),
502
+ 'alertCircle',
503
+ promotionReadiness.ready ? 'success' : 'danger',
504
+ )}
505
+ </div>
506
+
507
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-5)">
508
+ <div class="glass-card chart-card">
509
+ <p class="chart-title">${t('ui.dashboard.quality.domainStatus', {}, 'Domain status')}</p>
510
+ <div class="stack stack-sm" style="margin-top:var(--space-3)">
511
+ ${domains.length ? domains.map(([domain, counts]) => `
512
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)">
513
+ <span class="label-sm">${esc(domain)}</span>
514
+ <span class="text-muted" style="font-size:var(--text-xs)">
515
+ ${t('ui.dashboard.quality.domainCounts', {
516
+ pass: counts.pass || 0,
517
+ fail: counts.fail || 0,
518
+ skip: counts.skip || 0,
519
+ }, `pass ${counts.pass || 0} · fail ${counts.fail || 0} · skip ${counts.skip || 0}`)}
520
+ </span>
521
+ </div>
522
+ `).join('') : `<p class="text-muted">${t('ui.dashboard.quality.noDomainData', {}, 'No domain data.')}</p>`}
523
+ </div>
524
+ </div>
525
+
526
+ <div class="glass-card chart-card">
527
+ <p class="chart-title">${t('ui.dashboard.quality.latestVerification', {}, 'Latest verification')}</p>
528
+ <div class="stack stack-sm" style="margin-top:var(--space-3)">
529
+ ${Object.keys(latestRuns).length ? Object.entries(latestRuns).map(([scope, run]) => `
530
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)">
531
+ <span class="label-sm">${esc(t(`quality.scope.${scope}`, {}, scope))}</span>
532
+ <span class="badge ${run.status === 'passed' ? 'badge-success' : run.status === 'skipped' ? 'badge-warning' : 'badge-danger'}">${esc(statusLabel(run.status || 'unknown'))}</span>
533
+ </div>
534
+ `).join('') : `<p class="text-muted">${t('ui.dashboard.quality.noVerificationRuns', {}, 'No verification runs recorded.')}</p>`}
535
+ </div>
536
+ </div>
537
+
538
+ <div class="glass-card chart-card">
539
+ <p class="chart-title">${t('ui.dashboard.quality.topBlockers', {}, 'Top blockers')}</p>
540
+ <div class="stack stack-sm" style="margin-top:var(--space-3)">
541
+ ${failing.length ? failing.slice(0, 8).map((probe) => `
542
+ <div class="finding-item severity-${esc((probe.severity || 'medium').toLowerCase())}">
543
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(probe.id)}</p>
544
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(probe.message || '')}</p>
545
+ </div>
546
+ `).join('') : `<p class="text-muted">${t('ui.dashboard.quality.noFailingProbes', {}, 'No failing probes.')}</p>`}
547
+ </div>
548
+ </div>
549
+
550
+ <div class="glass-card chart-card">
551
+ <p class="chart-title">${t('ui.dashboard.quality.readinessDetail', {}, 'Readiness detail')}</p>
552
+ <div class="stack stack-sm" style="margin-top:var(--space-3)">
553
+ ${[phaseReadiness, releaseReadiness, promotionReadiness].map((item) => `
554
+ <div>
555
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(t(`ui.dashboard.quality.kind.${item.kind || 'readiness'}`, {}, item.kind || 'readiness'))} · ${esc(statusLabel(item.status || 'unknown'))}</p>
556
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(blockersLabel(item.blockers || []))}</p>
557
+ </div>
558
+ `).join('')}
559
+ </div>
560
+ </div>
561
+
562
+ <div class="glass-card chart-card">
563
+ <p class="chart-title">${t('ui.dashboard.quality.activeWaivers', {}, 'Active waivers')}</p>
564
+ <div class="stack stack-sm" style="margin-top:var(--space-3)">
565
+ ${waivers.length ? waivers.map((waiver) => `
566
+ <div>
567
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(waiver.probeId || waiver.id)}</p>
568
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">
569
+ ${esc(t('ui.dashboard.quality.waiverSummary', {
570
+ scope: waiver.scope || 'release',
571
+ approvedBy: waiver.approvedBy || 'unknown',
572
+ }, `${waiver.scope || 'release'} · ${waiver.approvedBy || 'unknown'}`))}
573
+ </p>
574
+ </div>
575
+ `).join('') : `<p class="text-muted">${t('ui.dashboard.quality.noWaivers', {}, 'No active waivers.')}</p>`}
576
+ </div>
577
+ </div>
578
+ </div>
579
+ </div>
580
+ `;
581
+ }
582
+
583
+ async function _renderAnalyticsTab(payload) {
427
584
  const { derived, control, runtime } = payload;
428
585
  const statusLabels = state.getStatusLabels();
429
586
  const history = extractHistory(control.tasks).slice(0, 20);
@@ -176,9 +176,13 @@ async function _runCommand() {
176
176
  const btn = document.getElementById('run-cmd-btn');
177
177
  if (btn) { btn.disabled = true; btn.innerHTML = `${icon('spinner', 15)} ${t('ui.execution.running', {}, 'Running…')}`; }
178
178
 
179
- try {
180
- const result = await api.runCommand(cmd);
181
- const sessionId = result.session?.id || result.sessionId;
179
+ try {
180
+ const selectedTaskId = state.get('selectedTaskId');
181
+ const result = await api.runCommand(cmd, {
182
+ taskId: selectedTaskId || undefined,
183
+ source: 'execution_console',
184
+ });
185
+ const sessionId = result.session?.id || result.sessionId;
182
186
 
183
187
  // Añadir sesión al estado
184
188
  const sessions = state.get('sessions');
@@ -0,0 +1,284 @@
1
+ import * as api from '../api.js';
2
+ import * as state from '../state.js';
3
+ import { icon } from '../icons.js';
4
+ import { esc, formatDate } from '../utils.js';
5
+ import { flash } from './flash.js';
6
+ import { t } from '../i18n.js';
7
+
8
+ let _selectedSourceId = null;
9
+ let _scanResults = [];
10
+ let _currentSource = null;
11
+
12
+ function renderSourceCard(source) {
13
+ const isActive = source.id === _selectedSourceId;
14
+ return `
15
+ <button class="panel ${isActive ? 'is-selected' : ''}" type="button" data-plan-source="${esc(source.id)}" style="text-align:left">
16
+ <div style="display:flex;justify-content:space-between;gap:var(--space-3);align-items:flex-start">
17
+ <div>
18
+ <p class="eyebrow">${esc(source.adapter || 'plan')}</p>
19
+ <h3 class="panel-title" style="margin:0">${esc(source.title || source.id)}</h3>
20
+ <p style="margin-top:var(--space-1);color:var(--text-secondary);font-size:var(--text-sm)">${esc(source.id)}</p>
21
+ </div>
22
+ <span class="badge badge-muted">${esc(source.status || 'previewed')}</span>
23
+ </div>
24
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-top:var(--space-3)">
25
+ <span class="badge badge-muted">Warnings: ${source.warnings || 0}</span>
26
+ <span class="badge ${(source.conflicts || 0) ? 'badge-danger' : 'badge-muted'}">Conflicts: ${source.conflicts || 0}</span>
27
+ <span class="badge badge-muted">Managed: ${source.managedTaskCount || 0}</span>
28
+ </div>
29
+ <div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--text-secondary)">
30
+ <div>Preview: ${source.lastPreviewAt ? esc(formatDate(source.lastPreviewAt, 'date')) : 'n/a'}</div>
31
+ <div>Applied: ${source.lastApplyAt ? esc(formatDate(source.lastApplyAt, 'date')) : 'n/a'}</div>
32
+ </div>
33
+ </button>
34
+ `;
35
+ }
36
+
37
+ function renderPreview(sourceDetails) {
38
+ if (!sourceDetails) {
39
+ return `<div class="empty-state">${t('ui.plans.selectSource', {}, 'Select a plan source to inspect its preview.')}</div>`;
40
+ }
41
+
42
+ const preview = sourceDetails.preview || {};
43
+ const operations = preview.operations || [];
44
+ const warnings = preview.warnings || [];
45
+
46
+ return `
47
+ <div class="stack stack-md">
48
+ <div class="panel">
49
+ <div style="display:flex;justify-content:space-between;gap:var(--space-3);align-items:flex-start;flex-wrap:wrap">
50
+ <div>
51
+ <p class="eyebrow">${esc(sourceDetails.source?.adapter || 'plan')}</p>
52
+ <h3 class="panel-title" style="margin:0">${esc(sourceDetails.source?.title || sourceDetails.source?.id || 'Plan')}</h3>
53
+ <p style="margin-top:var(--space-1);color:var(--text-secondary);font-size:var(--text-sm)">${esc(sourceDetails.source?.path || '')}</p>
54
+ </div>
55
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
56
+ <button class="btn btn-primary btn-sm" type="button" data-plan-apply="${esc(sourceDetails.source?.id || '')}">${icon('check', 14)} ${t('ui.plans.apply', {}, 'Apply')}</button>
57
+ <button class="btn btn-ghost btn-sm" type="button" data-plan-unlink="${esc(sourceDetails.source?.id || '')}">${icon('link', 14)} ${t('ui.plans.unlink', {}, 'Unlink')}</button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="grid-split">
63
+ <div class="panel">
64
+ <p class="eyebrow">${t('ui.plans.summary', {}, 'Summary')}</p>
65
+ <div class="stack stack-sm">
66
+ <div class="info-row"><p class="label-sm">Create</p><p class="value">${preview.summary?.create || 0}</p></div>
67
+ <div class="info-row"><p class="label-sm">Update</p><p class="value">${preview.summary?.update || 0}</p></div>
68
+ <div class="info-row"><p class="label-sm">Reparent</p><p class="value">${preview.summary?.reparent || 0}</p></div>
69
+ <div class="info-row"><p class="label-sm">Detach</p><p class="value">${preview.summary?.detach || 0}</p></div>
70
+ <div class="info-row"><p class="label-sm">Conflicts</p><p class="value">${preview.summary?.conflicts || 0}</p></div>
71
+ <div class="info-row"><p class="label-sm">Warnings</p><p class="value">${preview.summary?.warnings || 0}</p></div>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="panel">
76
+ <p class="eyebrow">${t('ui.plans.warnings', {}, 'Warnings')}</p>
77
+ ${(warnings.length
78
+ ? warnings.map((warning) => `<p style="font-size:var(--text-sm);color:var(--text-secondary)">${esc(warning)}</p>`).join('')
79
+ : `<p style="font-size:var(--text-sm);color:var(--text-secondary)">No parser warnings.</p>`)}
80
+ </div>
81
+ </div>
82
+
83
+ <div class="panel">
84
+ <p class="eyebrow">${t('ui.plans.operations', {}, 'Preview operations')}</p>
85
+ <div class="stack stack-sm">
86
+ ${operations.length
87
+ ? operations.map((operation) => `
88
+ <div class="task-card" style="cursor:default">
89
+ <div style="display:flex;justify-content:space-between;gap:var(--space-3);align-items:flex-start;flex-wrap:wrap">
90
+ <div>
91
+ <strong class="task-card-title">${esc(operation.title || operation.taskId || operation.externalNodeId)}</strong>
92
+ <span class="task-card-id">${esc(operation.taskId || operation.externalNodeId || '')}</span>
93
+ </div>
94
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
95
+ <span class="badge ${(operation.type === 'conflict') ? 'badge-danger' : 'badge-muted'}">${esc(operation.type)}</span>
96
+ ${operation.conflictFields?.length ? `<span class="badge badge-danger">${esc(operation.conflictFields.join(', '))}</span>` : ''}
97
+ ${operation.updatedFields?.length ? `<span class="badge badge-muted">${esc(operation.updatedFields.join(', '))}</span>` : ''}
98
+ </div>
99
+ </div>
100
+ </div>
101
+ `).join('')
102
+ : `<div class="empty-state">${t('ui.plans.noPreview', {}, 'No preview operations available.')}</div>`}
103
+ </div>
104
+ </div>
105
+ </div>
106
+ `;
107
+ }
108
+
109
+ function renderScanResults() {
110
+ if (!_scanResults.length) return '';
111
+ return `
112
+ <div class="panel">
113
+ <p class="eyebrow">${t('ui.plans.scanResults', {}, 'Detected candidates')}</p>
114
+ <div class="stack stack-sm">
115
+ ${_scanResults.map((item) => `
116
+ <div class="task-card" style="cursor:default">
117
+ <div style="display:flex;justify-content:space-between;gap:var(--space-3);align-items:flex-start;flex-wrap:wrap">
118
+ <div>
119
+ <strong class="task-card-title">${esc(item.title)}</strong>
120
+ <span class="task-card-id">${esc(item.path)}</span>
121
+ </div>
122
+ <div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
123
+ <span class="badge badge-muted">${esc(item.adapter)}</span>
124
+ <span class="badge badge-muted">${Math.round((item.confidence || 0) * 100)}%</span>
125
+ <button class="btn btn-ghost btn-sm" type="button" data-plan-import-path="${esc(item.path)}" data-plan-import-adapter="${esc(item.adapter)}">
126
+ ${icon('download', 14)} ${t('ui.plans.import', {}, 'Import')}
127
+ </button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ `).join('')}
132
+ </div>
133
+ </div>
134
+ `;
135
+ }
136
+
137
+ export async function render() {
138
+ const payload = state.getPayload();
139
+ const sourcesResult = await api.getPlans().catch(() => ({ sources: payload?.control?.meta?.plans?.sources || [] }));
140
+ const sources = sourcesResult.sources || [];
141
+
142
+ if (!_selectedSourceId && sources.length) {
143
+ _selectedSourceId = sources[0].id;
144
+ }
145
+
146
+ if (_selectedSourceId) {
147
+ _currentSource = await api.getPlan(_selectedSourceId).catch(() => null);
148
+ } else {
149
+ _currentSource = null;
150
+ }
151
+
152
+ return `
153
+ <section class="view-section view-enter">
154
+ <div class="section-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:var(--space-3)">
155
+ <div>
156
+ <p class="section-eyebrow">${esc(payload?.project?.name || 'TrackOps')}</p>
157
+ <h2 class="section-title">${t('ui.plans.title', {}, 'Plans')}</h2>
158
+ </div>
159
+ </div>
160
+
161
+ <div class="grid-split">
162
+ <div class="stack stack-md">
163
+ <div class="panel">
164
+ <p class="eyebrow">${t('ui.plans.importWizard', {}, 'Import wizard')}</p>
165
+ <form id="plans-import-form" class="stack stack-sm">
166
+ <div class="field">
167
+ <label for="plan-file">${t('ui.plans.file', {}, 'Plan file')}</label>
168
+ <input id="plan-file" type="text" placeholder="docs/implementation-plan.md" />
169
+ </div>
170
+ <div class="field-row">
171
+ <div class="field">
172
+ <label for="plan-adapter">${t('ui.plans.adapter', {}, 'Adapter')}</label>
173
+ <select id="plan-adapter">
174
+ <option value="auto">auto</option>
175
+ <option value="canonical">canonical</option>
176
+ <option value="claude">claude</option>
177
+ <option value="codex">codex</option>
178
+ <option value="antigravity">antigravity</option>
179
+ </select>
180
+ </div>
181
+ <div class="field">
182
+ <label for="plan-source-id">${t('ui.plans.sourceId', {}, 'Source id')}</label>
183
+ <input id="plan-source-id" type="text" placeholder="checkout-redesign-plan" />
184
+ </div>
185
+ </div>
186
+ <div class="form-actions" style="justify-content:flex-start">
187
+ <button class="btn btn-primary" type="submit">${icon('download', 14)} ${t('ui.plans.import', {}, 'Import')}</button>
188
+ <button class="btn btn-ghost" type="button" id="plans-scan-btn">${icon('search', 14)} ${t('ui.plans.scan', {}, 'Scan')}</button>
189
+ </div>
190
+ </form>
191
+ </div>
192
+
193
+ ${renderScanResults()}
194
+
195
+ <div class="stack stack-sm" id="plans-source-list">
196
+ ${sources.length
197
+ ? sources.map((source) => renderSourceCard(source)).join('')
198
+ : `<div class="empty-state">${t('ui.plans.empty', {}, 'No imported plans yet.')}</div>`}
199
+ </div>
200
+ </div>
201
+
202
+ <div id="plans-preview-panel">
203
+ ${renderPreview(_currentSource)}
204
+ </div>
205
+ </div>
206
+ </section>
207
+ `;
208
+ }
209
+
210
+ export function bindEvents() {
211
+ document.querySelectorAll('[data-plan-source]').forEach((button) => {
212
+ button.addEventListener('click', async () => {
213
+ _selectedSourceId = button.dataset.planSource;
214
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
215
+ });
216
+ });
217
+
218
+ document.getElementById('plans-scan-btn')?.addEventListener('click', async () => {
219
+ try {
220
+ const result = await api.scanPlans();
221
+ _scanResults = result.candidates || [];
222
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
223
+ } catch (error) {
224
+ flash(error.message, 'error');
225
+ }
226
+ });
227
+
228
+ document.querySelectorAll('[data-plan-import-path]').forEach((button) => {
229
+ button.addEventListener('click', async () => {
230
+ try {
231
+ await api.importPlan({ file: button.dataset.planImportPath, adapter: button.dataset.planImportAdapter || 'auto' });
232
+ _selectedSourceId = null;
233
+ _scanResults = [];
234
+ flash(t('ui.plans.imported', {}, 'Plan imported.'), 'success');
235
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
236
+ } catch (error) {
237
+ flash(error.message, 'error');
238
+ }
239
+ });
240
+ });
241
+
242
+ document.getElementById('plans-import-form')?.addEventListener('submit', async (event) => {
243
+ event.preventDefault();
244
+ const file = document.getElementById('plan-file')?.value.trim();
245
+ const adapter = document.getElementById('plan-adapter')?.value || 'auto';
246
+ const sourceId = document.getElementById('plan-source-id')?.value.trim();
247
+ if (!file) {
248
+ flash(t('ui.plans.fileRequired', {}, 'Plan file is required.'), 'error');
249
+ return;
250
+ }
251
+ try {
252
+ const result = await api.importPlan({ file, adapter, sourceId: sourceId || undefined });
253
+ _selectedSourceId = result.source?.id || _selectedSourceId;
254
+ _scanResults = [];
255
+ flash(t('ui.plans.imported', {}, 'Plan imported.'), 'success');
256
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
257
+ } catch (error) {
258
+ flash(error.message, 'error');
259
+ }
260
+ });
261
+
262
+ document.querySelector('[data-plan-apply]')?.addEventListener('click', async (event) => {
263
+ const sourceId = event.currentTarget.dataset.planApply;
264
+ try {
265
+ await api.applyPlan(sourceId, { conflicts: 'abort', removed: 'detach' });
266
+ flash(t('ui.plans.applied', {}, 'Plan applied.'), 'success');
267
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
268
+ } catch (error) {
269
+ flash(error.message, 'error');
270
+ }
271
+ });
272
+
273
+ document.querySelector('[data-plan-unlink]')?.addEventListener('click', async (event) => {
274
+ const sourceId = event.currentTarget.dataset.planUnlink;
275
+ try {
276
+ await api.unlinkPlan(sourceId, { keepTasks: true });
277
+ _selectedSourceId = null;
278
+ flash(t('ui.plans.unlinked', {}, 'Plan unlinked.'), 'success');
279
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
280
+ } catch (error) {
281
+ flash(error.message, 'error');
282
+ }
283
+ });
284
+ }