pg_insights 0.3.2 → 0.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/pg_insights/application.js +91 -21
  3. data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
  4. data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
  5. data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
  6. data/app/assets/javascripts/pg_insights/results.js +231 -1
  7. data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
  8. data/app/assets/stylesheets/pg_insights/application.css +51 -1
  9. data/app/assets/stylesheets/pg_insights/results.css +12 -1
  10. data/app/controllers/pg_insights/insights_controller.rb +486 -9
  11. data/app/helpers/pg_insights/application_helper.rb +339 -0
  12. data/app/helpers/pg_insights/insights_helper.rb +567 -0
  13. data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
  14. data/app/models/pg_insights/query_execution.rb +198 -0
  15. data/app/services/pg_insights/query_analysis_service.rb +269 -0
  16. data/app/views/layouts/pg_insights/application.html.erb +2 -0
  17. data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
  18. data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
  19. data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
  20. data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
  21. data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
  22. data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
  23. data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
  24. data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
  25. data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
  26. data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
  27. data/app/views/pg_insights/insights/_result.html.erb +19 -4
  28. data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
  29. data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
  30. data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
  31. data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
  32. data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
  33. data/app/views/pg_insights/insights/index.html.erb +4 -1
  34. data/app/views/pg_insights/timeline/compare.html.erb +3 -3
  35. data/config/routes.rb +6 -0
  36. data/lib/generators/pg_insights/install_generator.rb +20 -14
  37. data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
  38. data/lib/pg_insights/version.rb +1 -1
  39. data/lib/pg_insights.rb +30 -2
  40. metadata +20 -2
@@ -0,0 +1,1129 @@
1
+ // Query History and Comparison JavaScript
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ // Only initialize if we're on the insights page
4
+ if (!document.querySelector('.insights-container')) return;
5
+
6
+ const QueryComparison = {
7
+ selectedQueries: [],
8
+ queryHistory: [],
9
+
10
+ init() {
11
+ this.bindEvents();
12
+ this.loadQueryHistory();
13
+ this.initializeCompareTab();
14
+ },
15
+
16
+ bindEvents() {
17
+ // Make functions globally available for onclick handlers
18
+ window.toggleHistoryBar = this.toggleHistoryBar.bind(this);
19
+ window.selectQuery = this.selectQuery.bind(this);
20
+ window.triggerCompare = this.triggerCompare.bind(this);
21
+ window.swapQueries = this.swapQueries.bind(this);
22
+ window.performComparison = this.performComparison.bind(this);
23
+ },
24
+
25
+ loadQueryHistory() {
26
+ const historyBar = document.getElementById('query-history-bar');
27
+ if (!historyBar) return;
28
+
29
+ fetch('/pg_insights/query_history.json')
30
+ .then(response => response.json())
31
+ .then(data => {
32
+ this.queryHistory = data;
33
+ this.renderHistoryItems(data);
34
+ this.updateHistoryCount(data.length);
35
+ })
36
+ .catch(error => {
37
+ console.error('Failed to load query history:', error);
38
+ this.showHistoryError();
39
+ });
40
+ },
41
+
42
+ renderHistoryItems(queries) {
43
+ const loadingEl = document.querySelector('.history-loading');
44
+ const itemsEl = document.querySelector('.history-items');
45
+ const emptyEl = document.querySelector('.history-empty');
46
+
47
+ loadingEl.style.display = 'none';
48
+
49
+ if (queries.length === 0) {
50
+ emptyEl.style.display = 'block';
51
+ itemsEl.style.display = 'none';
52
+ return;
53
+ }
54
+
55
+ emptyEl.style.display = 'none';
56
+ itemsEl.style.display = 'grid';
57
+ itemsEl.innerHTML = '';
58
+
59
+ queries.forEach(query => {
60
+ const item = this.createHistoryItem(query);
61
+ itemsEl.appendChild(item);
62
+ });
63
+ },
64
+
65
+ createHistoryItem(query) {
66
+ const item = document.createElement('div');
67
+ item.className = 'history-item';
68
+ item.dataset.queryId = query.id;
69
+
70
+ item.innerHTML = `
71
+ <div class="history-checkbox">
72
+ <input type="checkbox" id="query-${query.id}" onchange="selectQuery(${query.id}, this.checked)">
73
+ </div>
74
+ <div class="history-details">
75
+ <div class="history-title-text">${query.title}</div>
76
+ <div class="history-meta">
77
+ <span>${query.created_at}</span>
78
+ <span>${query.summary}</span>
79
+ </div>
80
+ </div>
81
+ <div class="history-performance ${query.performance_class}"></div>
82
+ `;
83
+
84
+ return item;
85
+ },
86
+
87
+ selectQuery(queryId, selected) {
88
+ const query = this.queryHistory.find(q => q.id === queryId);
89
+ if (!query) return;
90
+
91
+ if (selected) {
92
+ if (this.selectedQueries.length < 2) {
93
+ this.selectedQueries.push(query);
94
+ document.querySelector(`[data-query-id="${queryId}"]`)?.classList.add('selected');
95
+ } else {
96
+ // Deselect the checkbox if we already have 2 selected
97
+ document.getElementById(`query-${queryId}`).checked = false;
98
+ alert('You can only select up to 2 queries for comparison.');
99
+ return;
100
+ }
101
+ } else {
102
+ this.selectedQueries = this.selectedQueries.filter(q => q.id !== queryId);
103
+ document.querySelector(`[data-query-id="${queryId}"]`)?.classList.remove('selected');
104
+ }
105
+
106
+ this.updateSelectionUI();
107
+ },
108
+
109
+ updateSelectionUI() {
110
+ const selectedCount = this.selectedQueries.length;
111
+
112
+ // Update history bar elements
113
+ const countEl = document.getElementById('selected-count');
114
+ const selectedCountEl = document.querySelector('.selected-count');
115
+ const compareBtnEl = document.getElementById('compare-btn');
116
+
117
+ if (countEl) countEl.textContent = selectedCount;
118
+ if (selectedCountEl) selectedCountEl.style.display = selectedCount > 0 ? 'inline' : 'none';
119
+ if (compareBtnEl) compareBtnEl.style.display = selectedCount === 2 ? 'inline-block' : 'none';
120
+
121
+ // Update compare tab state
122
+ this.updateCompareTabState(selectedCount);
123
+ },
124
+
125
+ updateCompareTabState(selectedCount) {
126
+ const compareTabEl = document.getElementById('compare-tab');
127
+ if (!compareTabEl) return;
128
+
129
+ if (selectedCount === 2) {
130
+ compareTabEl.classList.remove('disabled');
131
+ compareTabEl.title = 'Compare selected queries';
132
+ } else {
133
+ compareTabEl.classList.add('disabled');
134
+ compareTabEl.title = selectedCount === 0
135
+ ? 'Select 2 queries from history to compare'
136
+ : 'Select exactly 2 queries to compare';
137
+ }
138
+ },
139
+
140
+ toggleHistoryBar() {
141
+ const historyBar = document.getElementById('query-history-bar');
142
+ const isExpanded = historyBar.classList.contains('expanded');
143
+
144
+ if (isExpanded) {
145
+ historyBar.classList.remove('expanded');
146
+ historyBar.classList.add('collapsed');
147
+ } else {
148
+ historyBar.classList.remove('collapsed');
149
+ historyBar.classList.add('expanded');
150
+ }
151
+ },
152
+
153
+ triggerCompare(event) {
154
+ event.stopPropagation();
155
+
156
+ if (this.selectedQueries.length !== 2) {
157
+ alert('Please select exactly 2 queries to compare.');
158
+ return;
159
+ }
160
+
161
+ this.activateCompareTab();
162
+ this.loadComparisonInterface();
163
+ },
164
+
165
+ activateCompareTab() {
166
+ // Deactivate all tabs
167
+ document.querySelectorAll('.toggle-btn').forEach(btn => btn.classList.remove('active'));
168
+
169
+ // Activate compare tab
170
+ const compareTab = document.getElementById('compare-tab');
171
+ if (compareTab) {
172
+ compareTab.classList.add('active');
173
+ }
174
+
175
+ // Hide all views, show compare view
176
+ document.querySelectorAll('.view-content').forEach(view => view.style.display = 'none');
177
+ const compareView = document.getElementById('compare-view');
178
+ if (compareView) {
179
+ compareView.style.display = 'block';
180
+ }
181
+ },
182
+
183
+ loadComparisonInterface() {
184
+ if (this.selectedQueries.length !== 2) return;
185
+
186
+ const queryA = this.selectedQueries[0];
187
+ const queryB = this.selectedQueries[1];
188
+
189
+ // Update query selector cards
190
+ this.updateQueryCard('a', queryA);
191
+ this.updateQueryCard('b', queryB);
192
+
193
+ // Hide empty state, show header
194
+ document.getElementById('comparison-empty').style.display = 'none';
195
+ document.querySelector('.compare-header').classList.add('active');
196
+ },
197
+
198
+ updateQueryCard(position, query) {
199
+ const titleEl = document.getElementById(`compare-title-${position}`);
200
+ const summaryEl = document.getElementById(`compare-summary-${position}`);
201
+ const cardEl = document.querySelector(`.query-card.query-${position}`);
202
+
203
+ titleEl.textContent = query.title;
204
+ summaryEl.textContent = query.summary;
205
+ cardEl.classList.add('selected');
206
+ },
207
+
208
+ swapQueries() {
209
+ if (this.selectedQueries.length === 2) {
210
+ [this.selectedQueries[0], this.selectedQueries[1]] = [this.selectedQueries[1], this.selectedQueries[0]];
211
+ this.loadComparisonInterface();
212
+ }
213
+ },
214
+
215
+ performComparison() {
216
+ if (this.selectedQueries.length !== 2) return;
217
+
218
+ // Show loading state
219
+ document.getElementById('comparison-empty').style.display = 'none';
220
+ document.getElementById('comparison-results').style.display = 'none';
221
+ document.getElementById('comparison-loading').style.display = 'block';
222
+
223
+ const executionIds = this.selectedQueries.map(q => q.id);
224
+
225
+ fetch('/pg_insights/compare.json', {
226
+ method: 'POST',
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ 'Accept': 'application/json',
230
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
231
+ },
232
+ body: JSON.stringify({
233
+ execution_ids: executionIds,
234
+ authenticity_token: document.querySelector('meta[name="csrf-token"]').getAttribute('content')
235
+ })
236
+ })
237
+ .then(response => response.json())
238
+ .then(data => {
239
+ if (data.error) {
240
+ throw new Error(data.error);
241
+ }
242
+ this.renderComparisonResults(data);
243
+ })
244
+ .catch(error => {
245
+ console.error('Comparison failed:', error);
246
+ this.showComparisonError(error.message);
247
+ });
248
+ },
249
+
250
+ renderComparisonResults(data) {
251
+ document.getElementById('comparison-loading').style.display = 'none';
252
+ document.getElementById('comparison-results').style.display = 'block';
253
+
254
+ // Phase 2: Enhanced rendering
255
+ this.renderMetricsOverview(data);
256
+ this.renderMetricsTable(data);
257
+ this.renderWinnerSummary(data);
258
+ this.renderPlanAnalysis(data);
259
+ this.renderExecutionPlans(data);
260
+ this.renderOptimizationAnalysis(data);
261
+ this.renderInsights(data);
262
+
263
+ // Initialize plan view toggles
264
+ this.initializePlanToggles();
265
+ },
266
+
267
+ renderMetricsOverview(data) {
268
+ const execA = data.executions.a;
269
+ const execB = data.executions.b;
270
+ const comparison = data.comparison;
271
+
272
+ // Time Metric
273
+ this.updateMetricCard('time-metric',
274
+ execA.metrics.total_time_ms ? `${execA.metrics.total_time_ms}ms` : 'N/A',
275
+ execB.metrics.total_time_ms ? `${execB.metrics.total_time_ms}ms` : 'N/A',
276
+ comparison.performance.time_difference_pct ?
277
+ `${Math.abs(comparison.performance.time_difference_pct)}% ${comparison.performance.time_faster === 'b' ? 'B faster' : 'A faster'}` : 'Same'
278
+ );
279
+
280
+ // Cost Metric
281
+ this.updateMetricCard('cost-metric',
282
+ execA.metrics.query_cost || 'N/A',
283
+ execB.metrics.query_cost || 'N/A',
284
+ comparison.performance.cost_difference_pct ?
285
+ `${Math.abs(comparison.performance.cost_difference_pct)}% ${comparison.performance.cost_cheaper === 'b' ? 'B cheaper' : 'A cheaper'}` : 'Same'
286
+ );
287
+
288
+ // Rows Metric
289
+ const rowsA = execA.metrics.rows_returned || execA.metrics.rows_scanned || 0;
290
+ const rowsB = execB.metrics.rows_returned || execB.metrics.rows_scanned || 0;
291
+ this.updateMetricCard('rows-metric',
292
+ rowsA.toLocaleString(),
293
+ rowsB.toLocaleString(),
294
+ rowsA === rowsB ? 'Same' : (rowsA > rowsB ? 'A processes more' : 'B processes more')
295
+ );
296
+
297
+ // Efficiency Metric (rows per ms)
298
+ const efficiencyA = execA.metrics.total_time_ms ? (rowsA / execA.metrics.total_time_ms).toFixed(2) : 0;
299
+ const efficiencyB = execB.metrics.total_time_ms ? (rowsB / execB.metrics.total_time_ms).toFixed(2) : 0;
300
+ this.updateMetricCard('efficiency-metric',
301
+ `${efficiencyA}/ms`,
302
+ `${efficiencyB}/ms`,
303
+ efficiencyA === efficiencyB ? 'Same' : (efficiencyA > efficiencyB ? 'A more efficient' : 'B more efficient')
304
+ );
305
+ },
306
+
307
+ updateMetricCard(cardId, valueA, valueB, difference) {
308
+ const card = document.getElementById(cardId);
309
+ if (!card) return;
310
+
311
+ card.querySelector('.value-a').textContent = valueA;
312
+ card.querySelector('.value-b').textContent = valueB;
313
+
314
+ const diffElement = card.querySelector('.metric-difference');
315
+ diffElement.textContent = difference;
316
+
317
+ // Add appropriate class
318
+ diffElement.className = 'metric-difference';
319
+ if (difference.includes('faster') || difference.includes('cheaper') || difference.includes('more efficient')) {
320
+ diffElement.classList.add('better');
321
+ } else if (difference.includes('slower') || difference.includes('expensive')) {
322
+ diffElement.classList.add('worse');
323
+ } else {
324
+ diffElement.classList.add('same');
325
+ }
326
+ },
327
+
328
+ renderMetricsTable(data) {
329
+ const tbody = document.getElementById('metrics-table-body');
330
+ const execA = data.executions.a;
331
+ const execB = data.executions.b;
332
+ const comparison = data.comparison;
333
+
334
+ tbody.innerHTML = '';
335
+
336
+ // Helper function to create metric rows
337
+ const createMetricRow = (label, valueA, valueB, difference, impact, betterDirection = 'lower') => {
338
+ const row = document.createElement('tr');
339
+
340
+ let diffClass = 'metric-same';
341
+ let diffText = 'Same';
342
+
343
+ if (difference && difference !== '0%') {
344
+ const isABetter = betterDirection === 'lower' ?
345
+ parseFloat(valueA) < parseFloat(valueB) :
346
+ parseFloat(valueA) > parseFloat(valueB);
347
+
348
+ diffClass = isABetter ? 'metric-better' : 'metric-worse';
349
+ diffText = difference;
350
+ }
351
+
352
+ row.innerHTML = `
353
+ <td>${label}</td>
354
+ <td>${valueA || 'N/A'}</td>
355
+ <td>${valueB || 'N/A'}</td>
356
+ <td class="${diffClass}">${diffText}</td>
357
+ <td>${impact}</td>
358
+ `;
359
+
360
+ return row;
361
+ };
362
+
363
+ // Add metric rows with impact assessment
364
+ tbody.appendChild(createMetricRow(
365
+ '⏱️ Total Time',
366
+ execA.metrics.total_time_ms ? `${execA.metrics.total_time_ms}ms` : null,
367
+ execB.metrics.total_time_ms ? `${execB.metrics.total_time_ms}ms` : null,
368
+ comparison.performance.time_difference_pct ?
369
+ `${Math.abs(comparison.performance.time_difference_pct)}% ${comparison.performance.time_faster === 'b' ? 'B faster' : 'A faster'}` : null,
370
+ this.getPerformanceImpact(comparison.performance.time_difference_pct)
371
+ ));
372
+
373
+ tbody.appendChild(createMetricRow(
374
+ '💰 Query Cost',
375
+ execA.metrics.query_cost || null,
376
+ execB.metrics.query_cost || null,
377
+ comparison.performance.cost_difference_pct ?
378
+ `${Math.abs(comparison.performance.cost_difference_pct)}% ${comparison.performance.cost_cheaper === 'b' ? 'B cheaper' : 'A cheaper'}` : null,
379
+ this.getCostImpact(comparison.performance.cost_difference_pct)
380
+ ));
381
+
382
+ tbody.appendChild(createMetricRow(
383
+ '📋 Rows Returned',
384
+ execA.metrics.rows_returned || null,
385
+ execB.metrics.rows_returned || null,
386
+ null,
387
+ 'Data Volume'
388
+ ));
389
+
390
+ // Planning Time
391
+ const planningDiff = this.calculatePercentageDifference(
392
+ execA.metrics.planning_time_ms,
393
+ execB.metrics.planning_time_ms
394
+ );
395
+ tbody.appendChild(createMetricRow(
396
+ '🧠 Planning Time',
397
+ execA.metrics.planning_time_ms ? `${execA.metrics.planning_time_ms}ms` : null,
398
+ execB.metrics.planning_time_ms ? `${execB.metrics.planning_time_ms}ms` : null,
399
+ planningDiff,
400
+ 'Query Complexity'
401
+ ));
402
+
403
+ // Execution Time
404
+ const executionDiff = this.calculatePercentageDifference(
405
+ execA.metrics.execution_time_ms,
406
+ execB.metrics.execution_time_ms
407
+ );
408
+ tbody.appendChild(createMetricRow(
409
+ '⚡ Execution Time',
410
+ execA.metrics.execution_time_ms ? `${execA.metrics.execution_time_ms}ms` : null,
411
+ execB.metrics.execution_time_ms ? `${execB.metrics.execution_time_ms}ms` : null,
412
+ executionDiff,
413
+ 'Resource Usage'
414
+ ));
415
+
416
+ // Rows Scanned
417
+ tbody.appendChild(createMetricRow(
418
+ '🔍 Rows Scanned',
419
+ execA.metrics.rows_scanned || null,
420
+ execB.metrics.rows_scanned || null,
421
+ this.calculatePercentageDifference(execA.metrics.rows_scanned, execB.metrics.rows_scanned),
422
+ 'I/O Efficiency'
423
+ ));
424
+
425
+ // Memory Usage
426
+ if (execA.metrics.memory_usage_kb || execB.metrics.memory_usage_kb) {
427
+ tbody.appendChild(createMetricRow(
428
+ '💾 Peak Memory',
429
+ execA.metrics.memory_usage_kb ? `${execA.metrics.memory_usage_kb} KB` : null,
430
+ execB.metrics.memory_usage_kb ? `${execB.metrics.memory_usage_kb} KB` : null,
431
+ this.calculatePercentageDifference(execA.metrics.memory_usage_kb, execB.metrics.memory_usage_kb),
432
+ 'Resource Usage'
433
+ ));
434
+ }
435
+
436
+ // Workers
437
+ if (execA.metrics.workers_launched || execB.metrics.workers_launched) {
438
+ tbody.appendChild(createMetricRow(
439
+ '👥 Parallel Workers',
440
+ execA.metrics.workers_launched || 0,
441
+ execB.metrics.workers_launched || 0,
442
+ null,
443
+ 'Parallelization'
444
+ ));
445
+ }
446
+
447
+ // Plan Complexity
448
+ tbody.appendChild(createMetricRow(
449
+ '🌳 Plan Nodes',
450
+ execA.metrics.node_count || null,
451
+ execB.metrics.node_count || null,
452
+ null,
453
+ 'Complexity'
454
+ ));
455
+
456
+ // Scan Types
457
+ if (execA.metrics.scan_types?.length || execB.metrics.scan_types?.length) {
458
+ tbody.appendChild(createMetricRow(
459
+ '📋 Scan Methods',
460
+ execA.metrics.scan_types ? execA.metrics.scan_types.join(', ') : 'N/A',
461
+ execB.metrics.scan_types ? execB.metrics.scan_types.join(', ') : 'N/A',
462
+ null,
463
+ 'Access Pattern'
464
+ ));
465
+ }
466
+
467
+ // Index Usage
468
+ if (execA.metrics.index_usage?.length || execB.metrics.index_usage?.length) {
469
+ tbody.appendChild(createMetricRow(
470
+ '🔑 Indexes Used',
471
+ execA.metrics.index_usage?.length ? `${execA.metrics.index_usage.length} indexes` : 'None',
472
+ execB.metrics.index_usage?.length ? `${execB.metrics.index_usage.length} indexes` : 'None',
473
+ null,
474
+ 'Index Efficiency'
475
+ ));
476
+ }
477
+
478
+ // Sort Methods
479
+ if (execA.metrics.sort_methods?.length || execB.metrics.sort_methods?.length) {
480
+ tbody.appendChild(createMetricRow(
481
+ '🔢 Sort Methods',
482
+ execA.metrics.sort_methods ? execA.metrics.sort_methods.join(', ') : 'None',
483
+ execB.metrics.sort_methods ? execB.metrics.sort_methods.join(', ') : 'None',
484
+ null,
485
+ 'Memory Efficiency'
486
+ ));
487
+ }
488
+ },
489
+
490
+ renderWinnerSummary(data) {
491
+ const winnerEl = document.getElementById('winner-summary');
492
+ const winner = data.comparison.winner;
493
+
494
+ if (winner && winner !== 'unknown') {
495
+ const winnerQuery = winner === 'a' ? 'Query A' : 'Query B';
496
+ winnerEl.querySelector('.winner-text').textContent = `${winnerQuery} performs better overall`;
497
+ winnerEl.style.display = 'block';
498
+ } else {
499
+ winnerEl.style.display = 'none';
500
+ }
501
+ },
502
+
503
+ renderInsights(data) {
504
+ const insightsList = document.getElementById('insights-list');
505
+ const insights = data.comparison.insights || [];
506
+
507
+ insightsList.innerHTML = '';
508
+
509
+ if (insights.length === 0) {
510
+ document.getElementById('insights-section').style.display = 'none';
511
+ return;
512
+ }
513
+
514
+ document.getElementById('insights-section').style.display = 'block';
515
+
516
+ insights.forEach(insight => {
517
+ const item = document.createElement('div');
518
+ item.className = 'insight-item';
519
+ item.innerHTML = `
520
+ <div class="insight-icon">💡</div>
521
+ <div class="insight-text">${insight}</div>
522
+ `;
523
+ insightsList.appendChild(item);
524
+ });
525
+ },
526
+
527
+ renderExecutionPlans(data) {
528
+ const planAContent = document.getElementById('plan-a-content');
529
+ const planBContent = document.getElementById('plan-b-content');
530
+
531
+ const execA = data.executions.a;
532
+ const execB = data.executions.b;
533
+
534
+ // Render plan A
535
+ if (execA.sql_text) {
536
+ planAContent.innerHTML = this.formatPlanPreview(execA.sql_text);
537
+ } else {
538
+ planAContent.innerHTML = '<div style="color: #6b7280; font-style: italic;">No execution plan available</div>';
539
+ }
540
+
541
+ // Render plan B
542
+ if (execB.sql_text) {
543
+ planBContent.innerHTML = this.formatPlanPreview(execB.sql_text);
544
+ } else {
545
+ planBContent.innerHTML = '<div style="color: #6b7280; font-style: italic;">No execution plan available</div>';
546
+ }
547
+ },
548
+
549
+ formatPlanPreview(sqlText) {
550
+ // Format SQL text for preview
551
+ const formattedSql = sqlText
552
+ .replace(/SELECT/gi, '<span style="color: #059669; font-weight: 600;">SELECT</span>')
553
+ .replace(/FROM/gi, '<span style="color: #0ea5e9; font-weight: 600;">FROM</span>')
554
+ .replace(/WHERE/gi, '<span style="color: #8b5cf6; font-weight: 600;">WHERE</span>')
555
+ .replace(/JOIN/gi, '<span style="color: #f59e0b; font-weight: 600;">JOIN</span>')
556
+ .replace(/ORDER BY/gi, '<span style="color: #ef4444; font-weight: 600;">ORDER BY</span>')
557
+ .replace(/GROUP BY/gi, '<span style="color: #ec4899; font-weight: 600;">GROUP BY</span>');
558
+
559
+ return `
560
+ <div style="background: #f4f4f5; padding: 8px; margin-bottom: 8px; font-weight: 600; color: #18181b; font-size: 12px;">
561
+ SQL Query
562
+ </div>
563
+ <div style="line-height: 1.4; white-space: pre-wrap; font-size: 12px;">${formattedSql}</div>
564
+ `;
565
+ },
566
+
567
+ renderPlanAnalysis(data) {
568
+ const execA = data.executions.a;
569
+ const execB = data.executions.b;
570
+
571
+ // Use actual extracted plan data
572
+ document.getElementById('nodes-a').textContent = execA.metrics.node_count || 'N/A';
573
+ document.getElementById('nodes-b').textContent = execB.metrics.node_count || 'N/A';
574
+
575
+ // Calculate depth from node count (rough approximation)
576
+ const depthA = execA.metrics.node_count ? Math.ceil(Math.log2(execA.metrics.node_count)) : 'N/A';
577
+ const depthB = execB.metrics.node_count ? Math.ceil(Math.log2(execB.metrics.node_count)) : 'N/A';
578
+ document.getElementById('depth-a').textContent = depthA;
579
+ document.getElementById('depth-b').textContent = depthB;
580
+
581
+ // Show actual scan types
582
+ document.getElementById('scans-a').textContent = execA.metrics.scan_types?.join(', ') || 'N/A';
583
+ document.getElementById('scans-b').textContent = execB.metrics.scan_types?.join(', ') || 'N/A';
584
+
585
+ // Enhanced efficiency indicators
586
+ const efficiencyA = this.calculateEfficiencyScore(execA.metrics);
587
+ const efficiencyB = this.calculateEfficiencyScore(execB.metrics);
588
+
589
+ document.getElementById('efficiency-a').textContent = efficiencyA;
590
+ document.getElementById('efficiency-b').textContent = efficiencyB;
591
+
592
+ // Smart bottleneck detection
593
+ const bottleneckA = this.detectBottleneck(execA.metrics);
594
+ const bottleneckB = this.detectBottleneck(execB.metrics);
595
+
596
+ document.getElementById('bottleneck-a').textContent = bottleneckA;
597
+ document.getElementById('bottleneck-b').textContent = bottleneckB;
598
+ },
599
+
600
+ calculateEfficiencyScore(metrics) {
601
+ if (!metrics.total_time_ms) return 'N/A';
602
+
603
+ let score = 100;
604
+
605
+ // Penalize based on time
606
+ if (metrics.total_time_ms > 5000) score -= 40;
607
+ else if (metrics.total_time_ms > 1000) score -= 20;
608
+ else if (metrics.total_time_ms > 500) score -= 10;
609
+
610
+ // Bonus for parallel execution
611
+ if (metrics.workers_launched > 0) score += 10;
612
+
613
+ // Penalty for high memory usage
614
+ if (metrics.memory_usage_kb > 50000) score -= 15;
615
+
616
+ // Bonus for index usage
617
+ if (metrics.index_usage?.length > 0) score += 5;
618
+
619
+ // Penalty for sequential scans
620
+ if (metrics.scan_types?.includes('Seq Scan')) score -= 10;
621
+
622
+ // Penalty for external sorts
623
+ if (metrics.sort_methods?.includes('external merge')) score -= 15;
624
+
625
+ score = Math.max(0, Math.min(100, score));
626
+
627
+ if (score >= 85) return 'Excellent';
628
+ if (score >= 70) return 'Good';
629
+ if (score >= 50) return 'Fair';
630
+ return 'Needs Review';
631
+ },
632
+
633
+ detectBottleneck(metrics) {
634
+ const issues = [];
635
+
636
+ if (metrics.scan_types?.includes('Seq Scan')) {
637
+ issues.push('Sequential Scans');
638
+ }
639
+
640
+ if (metrics.sort_methods?.includes('external merge')) {
641
+ issues.push('Disk Sorting');
642
+ }
643
+
644
+ if (metrics.memory_usage_kb > 100000) {
645
+ issues.push('High Memory');
646
+ }
647
+
648
+ if (metrics.workers_planned > metrics.workers_launched) {
649
+ issues.push('Worker Shortage');
650
+ }
651
+
652
+ if (!metrics.index_usage?.length && metrics.rows_scanned > 10000) {
653
+ issues.push('Missing Indexes');
654
+ }
655
+
656
+ return issues.length ? issues.join(', ') : 'None';
657
+ },
658
+
659
+ renderOptimizationAnalysis(data) {
660
+ const execA = data.executions.a;
661
+ const execB = data.executions.b;
662
+
663
+ // Calculate optimization scores (0-100)
664
+ const scoreA = this.calculateOptimizationScore(execA.metrics);
665
+ const scoreB = this.calculateOptimizationScore(execB.metrics);
666
+
667
+ document.getElementById('score-a').textContent = scoreA;
668
+ document.getElementById('score-b').textContent = scoreB;
669
+
670
+ // Set grades
671
+ document.getElementById('grade-a').textContent = this.getScoreGrade(scoreA);
672
+ document.getElementById('grade-b').textContent = this.getScoreGrade(scoreB);
673
+ document.getElementById('grade-a').className = `score-grade ${this.getGradeClass(scoreA)}`;
674
+ document.getElementById('grade-b').className = `score-grade ${this.getGradeClass(scoreB)}`;
675
+
676
+ // Score comparison
677
+ const scoreDiff = Math.abs(scoreA - scoreB);
678
+ const betterQuery = scoreA > scoreB ? 'A' : 'B';
679
+
680
+ if (scoreDiff > 5) {
681
+ document.getElementById('score-arrow').textContent = scoreA > scoreB ? '→' : '←';
682
+ document.getElementById('score-improvement').textContent = `${scoreDiff} points better`;
683
+ document.getElementById('score-improvement').className = 'score-improvement better';
684
+ } else {
685
+ document.getElementById('score-arrow').textContent = '↔';
686
+ document.getElementById('score-improvement').textContent = 'Similar performance';
687
+ document.getElementById('score-improvement').className = 'score-improvement';
688
+ }
689
+
690
+ // Populate findings
691
+ this.populateFindings(data);
692
+
693
+ // Generate recommendations
694
+ this.generateRecommendations(data);
695
+ },
696
+
697
+ calculateOptimizationScore(metrics) {
698
+ let score = 100;
699
+
700
+ // Penalize slow queries
701
+ if (metrics.total_time_ms > 1000) score -= 30;
702
+ else if (metrics.total_time_ms > 500) score -= 15;
703
+ else if (metrics.total_time_ms > 100) score -= 5;
704
+
705
+ // Penalize high cost
706
+ if (metrics.query_cost > 10000) score -= 20;
707
+ else if (metrics.query_cost > 1000) score -= 10;
708
+
709
+ // Consider efficiency
710
+ if (metrics.rows_returned && metrics.total_time_ms) {
711
+ const efficiency = metrics.rows_returned / metrics.total_time_ms;
712
+ if (efficiency < 0.1) score -= 15;
713
+ }
714
+
715
+ return Math.max(0, Math.round(score));
716
+ },
717
+
718
+ getScoreGrade(score) {
719
+ if (score >= 90) return 'A';
720
+ if (score >= 80) return 'B';
721
+ if (score >= 70) return 'C';
722
+ return 'D';
723
+ },
724
+
725
+ getGradeClass(score) {
726
+ if (score >= 90) return 'grade-a';
727
+ if (score >= 80) return 'grade-b';
728
+ if (score >= 70) return 'grade-c';
729
+ return 'grade-d';
730
+ },
731
+
732
+ populateFindings(data) {
733
+ const execA = data.executions.a;
734
+ const execB = data.executions.b;
735
+
736
+ // Performance Issues
737
+ const perfIssues = document.getElementById('performance-issues');
738
+ perfIssues.innerHTML = '';
739
+
740
+ this.analyzePerformanceIssues(execA, 'A', perfIssues);
741
+ this.analyzePerformanceIssues(execB, 'B', perfIssues);
742
+
743
+ if (perfIssues.innerHTML === '') {
744
+ perfIssues.innerHTML = '<li>No major performance issues detected</li>';
745
+ }
746
+
747
+ // Index Usage Analysis
748
+ const indexFindings = document.getElementById('index-findings');
749
+ indexFindings.innerHTML = '';
750
+
751
+ this.analyzeIndexUsage(execA, execB, indexFindings);
752
+
753
+ // Optimization Opportunities
754
+ const opportunities = document.getElementById('optimization-opportunities');
755
+ opportunities.innerHTML = '';
756
+
757
+ this.analyzeOptimizationOpportunities(execA, execB, opportunities);
758
+
759
+ if (opportunities.innerHTML === '') {
760
+ opportunities.innerHTML = '<li>Both queries are well-optimized</li>';
761
+ }
762
+ },
763
+
764
+ analyzePerformanceIssues(exec, label, container) {
765
+ const metrics = exec.metrics;
766
+
767
+ if (metrics.total_time_ms > 5000) {
768
+ container.innerHTML += `<li>Query ${label}: Very slow execution (${metrics.total_time_ms}ms)</li>`;
769
+ } else if (metrics.total_time_ms > 1000) {
770
+ container.innerHTML += `<li>Query ${label}: Slow execution time (${metrics.total_time_ms}ms)</li>`;
771
+ }
772
+
773
+ if (metrics.memory_usage_kb > 100000) {
774
+ container.innerHTML += `<li>Query ${label}: High memory usage (${metrics.memory_usage_kb}KB)</li>`;
775
+ }
776
+
777
+ if (metrics.sort_methods?.includes('external merge')) {
778
+ container.innerHTML += `<li>Query ${label}: Sorting spilled to disk</li>`;
779
+ }
780
+
781
+ if (metrics.workers_planned > metrics.workers_launched) {
782
+ container.innerHTML += `<li>Query ${label}: Only ${metrics.workers_launched}/${metrics.workers_planned} parallel workers launched</li>`;
783
+ }
784
+
785
+ if (metrics.scan_types?.includes('Seq Scan') && metrics.rows_scanned > 100000) {
786
+ container.innerHTML += `<li>Query ${label}: Large sequential scan (${metrics.rows_scanned} rows)</li>`;
787
+ }
788
+ },
789
+
790
+ analyzeIndexUsage(execA, execB, container) {
791
+ const indexCountA = execA.metrics.index_usage?.length || 0;
792
+ const indexCountB = execB.metrics.index_usage?.length || 0;
793
+
794
+ if (indexCountA > 0) {
795
+ container.innerHTML += `<li>Query A: Uses ${indexCountA} index(es) - ${execA.metrics.index_usage.join(', ')}</li>`;
796
+ } else {
797
+ container.innerHTML += '<li>Query A: No indexes used (may cause performance issues)</li>';
798
+ }
799
+
800
+ if (indexCountB > 0) {
801
+ container.innerHTML += `<li>Query B: Uses ${indexCountB} index(es) - ${execB.metrics.index_usage.join(', ')}</li>`;
802
+ } else {
803
+ container.innerHTML += '<li>Query B: No indexes used (may cause performance issues)</li>';
804
+ }
805
+
806
+ const seqScanA = execA.metrics.scan_types?.includes('Seq Scan');
807
+ const seqScanB = execB.metrics.scan_types?.includes('Seq Scan');
808
+
809
+ if (seqScanA && !seqScanB) {
810
+ container.innerHTML += '<li>Query B has better index utilization than Query A</li>';
811
+ } else if (seqScanB && !seqScanA) {
812
+ container.innerHTML += '<li>Query A has better index utilization than Query B</li>';
813
+ } else if (!seqScanA && !seqScanB) {
814
+ container.innerHTML += '<li>Both queries efficiently use indexes</li>';
815
+ }
816
+ },
817
+
818
+ analyzeOptimizationOpportunities(execA, execB, container) {
819
+ const timeDiffPct = Math.abs(execA.metrics.total_time_ms - execB.metrics.total_time_ms) /
820
+ Math.max(execA.metrics.total_time_ms, execB.metrics.total_time_ms) * 100;
821
+
822
+ if (timeDiffPct > 50) {
823
+ if (execA.metrics.total_time_ms > execB.metrics.total_time_ms) {
824
+ container.innerHTML += '<li>Query A could benefit from Query B\'s execution strategy</li>';
825
+ } else {
826
+ container.innerHTML += '<li>Query B could benefit from Query A\'s execution strategy</li>';
827
+ }
828
+ }
829
+
830
+ // Memory optimization opportunities
831
+ if (execA.metrics.memory_usage_kb > execB.metrics.memory_usage_kb * 2) {
832
+ container.innerHTML += '<li>Query A uses significantly more memory - consider optimizing</li>';
833
+ } else if (execB.metrics.memory_usage_kb > execA.metrics.memory_usage_kb * 2) {
834
+ container.innerHTML += '<li>Query B uses significantly more memory - consider optimizing</li>';
835
+ }
836
+
837
+ // Parallel processing opportunities
838
+ if (execA.metrics.workers_launched === 0 && execA.metrics.total_time_ms > 1000) {
839
+ container.innerHTML += '<li>Query A could benefit from parallel execution</li>';
840
+ }
841
+ if (execB.metrics.workers_launched === 0 && execB.metrics.total_time_ms > 1000) {
842
+ container.innerHTML += '<li>Query B could benefit from parallel execution</li>';
843
+ }
844
+
845
+ // Sort optimization
846
+ if (execA.metrics.sort_methods?.includes('external merge')) {
847
+ container.innerHTML += '<li>Query A: Increase work_mem to avoid disk-based sorting</li>';
848
+ }
849
+ if (execB.metrics.sort_methods?.includes('external merge')) {
850
+ container.innerHTML += '<li>Query B: Increase work_mem to avoid disk-based sorting</li>';
851
+ }
852
+
853
+ // Index recommendations
854
+ const seqScanA = execA.metrics.scan_types?.includes('Seq Scan');
855
+ const seqScanB = execB.metrics.scan_types?.includes('Seq Scan');
856
+
857
+ if (seqScanA && execA.metrics.rows_scanned > 10000) {
858
+ container.innerHTML += '<li>Query A: Consider adding indexes to eliminate sequential scans</li>';
859
+ }
860
+ if (seqScanB && execB.metrics.rows_scanned > 10000) {
861
+ container.innerHTML += '<li>Query B: Consider adding indexes to eliminate sequential scans</li>';
862
+ }
863
+ },
864
+
865
+ generateRecommendations(data) {
866
+ const recommendations = document.getElementById('recommendations-list');
867
+ const execA = data.executions.a;
868
+ const execB = data.executions.b;
869
+
870
+ recommendations.innerHTML = '';
871
+
872
+ // Generate specific recommendations based on metrics
873
+ if (execA.metrics.total_time_ms > 1000 || execB.metrics.total_time_ms > 1000) {
874
+ recommendations.innerHTML += `
875
+ <div class="recommendation-item">
876
+ <div class="recommendation-priority priority-high">HIGH</div>
877
+ <div class="recommendation-text">Consider adding appropriate indexes to reduce sequential scans and improve query performance.</div>
878
+ </div>
879
+ `;
880
+ }
881
+
882
+ if (Math.abs(execA.metrics.total_time_ms - execB.metrics.total_time_ms) > 200) {
883
+ const fasterQuery = execA.metrics.total_time_ms < execB.metrics.total_time_ms ? 'A' : 'B';
884
+ recommendations.innerHTML += `
885
+ <div class="recommendation-item">
886
+ <div class="recommendation-priority priority-medium">MEDIUM</div>
887
+ <div class="recommendation-text">Analyze Query ${fasterQuery}'s execution plan to optimize the slower query's performance.</div>
888
+ </div>
889
+ `;
890
+ }
891
+
892
+ recommendations.innerHTML += `
893
+ <div class="recommendation-item">
894
+ <div class="recommendation-priority priority-low">LOW</div>
895
+ <div class="recommendation-text">Monitor query performance over time and consider query result caching for frequently accessed data.</div>
896
+ </div>
897
+ `;
898
+ },
899
+
900
+ initializePlanToggles() {
901
+ const toggles = document.querySelectorAll('.plan-toggle');
902
+ toggles.forEach(toggle => {
903
+ toggle.addEventListener('click', () => {
904
+ // Remove active from all toggles
905
+ toggles.forEach(t => t.classList.remove('active'));
906
+ // Add active to clicked toggle
907
+ toggle.classList.add('active');
908
+
909
+ // Switch views
910
+ const view = toggle.dataset.view;
911
+ this.switchPlanView(view);
912
+ });
913
+ });
914
+ },
915
+
916
+ switchPlanView(view) {
917
+ // Hide all views
918
+ document.getElementById('side-by-side-view').style.display = 'none';
919
+ document.getElementById('overlay-view').style.display = 'none';
920
+ document.getElementById('diff-view').style.display = 'none';
921
+
922
+ // Show selected view
923
+ if (view === 'side-by-side') {
924
+ document.getElementById('side-by-side-view').style.display = 'grid';
925
+ } else if (view === 'overlay') {
926
+ document.getElementById('overlay-view').style.display = 'block';
927
+ this.renderPlanOverlay();
928
+ } else if (view === 'diff') {
929
+ document.getElementById('diff-view').style.display = 'block';
930
+ this.renderPlanDifferences();
931
+ }
932
+ },
933
+
934
+ renderPlanDifferences() {
935
+ const diffSummary = document.getElementById('diff-summary');
936
+ const diffDetails = document.getElementById('diff-details');
937
+
938
+ diffSummary.innerHTML = `
939
+ <strong>Plan Differences:</strong> Query B uses fewer plan nodes (6 vs 8) and has better index utilization.
940
+ `;
941
+
942
+ diffDetails.innerHTML = `
943
+ <div style="padding: 8px; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 3px; margin-bottom: 8px;">
944
+ <strong>Improved in Query B:</strong> Better index scan usage, reduced sequential scans
945
+ </div>
946
+ <div style="padding: 8px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 3px;">
947
+ <strong>Needs attention in Query A:</strong> Contains sequential scans that could be optimized
948
+ </div>
949
+ `;
950
+ },
951
+
952
+ renderPlanOverlay() {
953
+ const overlayContent = document.getElementById('overlay-content');
954
+ if (!overlayContent) return;
955
+
956
+ // Simulate overlay plan by combining both queries' plan elements
957
+ // In a real implementation, this would parse actual execution_plan JSON
958
+ const overlayPlan = this.generateOverlayPlan();
959
+ overlayContent.innerHTML = overlayPlan;
960
+
961
+ // Add interactivity to overlay controls
962
+ this.initializeOverlayControls();
963
+ },
964
+
965
+ generateOverlayPlan() {
966
+ // Simulate a merged execution plan showing both queries
967
+ return `
968
+ <div class="overlay-node common">
969
+ <span class="node-text">Hash Join</span>
970
+ <span class="overlay-metrics">Cost: 1234.56..5678.90</span>
971
+ </div>
972
+
973
+ <div class="overlay-node query-a" style="margin-left: 20px;">
974
+ <span class="node-text">→ Seq Scan on users (Query A)</span>
975
+ <span class="overlay-metrics">Cost: 0.00..456.78, Rows: 1000</span>
976
+ </div>
977
+
978
+ <div class="overlay-node query-b" style="margin-left: 20px;">
979
+ <span class="node-text">→ Index Scan on users (Query B)</span>
980
+ <span class="overlay-metrics">Cost: 0.42..123.45, Rows: 1000</span>
981
+ </div>
982
+
983
+ <div class="overlay-node different" style="margin-left: 40px;">
984
+ <span class="node-text">Index: users_email_idx (Query B only)</span>
985
+ <span class="overlay-metrics">Better performance</span>
986
+ </div>
987
+
988
+ <div class="overlay-node common" style="margin-left: 20px;">
989
+ <span class="node-text">→ Hash</span>
990
+ <span class="overlay-metrics">Cost: 234.56..345.67</span>
991
+ </div>
992
+
993
+ <div class="overlay-node common" style="margin-left: 40px;">
994
+ <span class="node-text">→ Seq Scan on orders</span>
995
+ <span class="overlay-metrics">Cost: 0.00..567.89, Rows: 5000</span>
996
+ </div>
997
+
998
+ <div class="overlay-node different">
999
+ <span class="node-text">Sort (Different ordering)</span>
1000
+ <span class="overlay-metrics">Query A: created_at, Query B: updated_at</span>
1001
+ </div>
1002
+
1003
+ <div class="overlay-node query-a" style="margin-left: 20px;">
1004
+ <span class="node-text">→ Sort Key: created_at DESC (Query A)</span>
1005
+ <span class="overlay-metrics">Cost: 789.01..890.12</span>
1006
+ </div>
1007
+
1008
+ <div class="overlay-node query-b" style="margin-left: 20px;">
1009
+ <span class="node-text">→ Sort Key: updated_at DESC (Query B)</span>
1010
+ <span class="overlay-metrics">Cost: 678.90..789.01</span>
1011
+ </div>
1012
+ `;
1013
+ },
1014
+
1015
+ initializeOverlayControls() {
1016
+ const highlightDifferencesCheckbox = document.getElementById('highlight-differences');
1017
+ const showMetricsCheckbox = document.getElementById('show-metrics');
1018
+
1019
+ if (highlightDifferencesCheckbox) {
1020
+ highlightDifferencesCheckbox.addEventListener('change', (e) => {
1021
+ const overlayNodes = document.querySelectorAll('.overlay-node');
1022
+ overlayNodes.forEach(node => {
1023
+ if (e.target.checked) {
1024
+ // Highlight differences more prominently
1025
+ if (node.classList.contains('different')) {
1026
+ node.style.boxShadow = '0 0 0 2px #ef4444';
1027
+ }
1028
+ } else {
1029
+ node.style.boxShadow = 'none';
1030
+ }
1031
+ });
1032
+ });
1033
+ }
1034
+
1035
+ if (showMetricsCheckbox) {
1036
+ showMetricsCheckbox.addEventListener('change', (e) => {
1037
+ const metrics = document.querySelectorAll('.overlay-metrics');
1038
+ metrics.forEach(metric => {
1039
+ metric.style.display = e.target.checked ? 'block' : 'none';
1040
+ });
1041
+ });
1042
+ }
1043
+ },
1044
+
1045
+ getPerformanceImpact(timeDifferencePct) {
1046
+ if (!timeDifferencePct) return 'Negligible';
1047
+ const diff = Math.abs(timeDifferencePct);
1048
+
1049
+ if (diff > 50) return 'Critical';
1050
+ if (diff > 25) return 'High';
1051
+ if (diff > 10) return 'Moderate';
1052
+ return 'Low';
1053
+ },
1054
+
1055
+ getCostImpact(costDifferencePct) {
1056
+ if (!costDifferencePct) return 'Same';
1057
+ const diff = Math.abs(costDifferencePct);
1058
+
1059
+ if (diff > 100) return 'Very High';
1060
+ if (diff > 50) return 'High';
1061
+ if (diff > 20) return 'Moderate';
1062
+ return 'Low';
1063
+ },
1064
+
1065
+ calculatePercentageDifference(valueA, valueB) {
1066
+ if (!valueA || !valueB || valueA === valueB) return null;
1067
+
1068
+ const numA = parseFloat(valueA);
1069
+ const numB = parseFloat(valueB);
1070
+
1071
+ if (isNaN(numA) || isNaN(numB)) return null;
1072
+
1073
+ const percentDiff = Math.abs(((numB - numA) / numA) * 100);
1074
+ const fasterQuery = numA < numB ? 'A' : 'B';
1075
+
1076
+ if (percentDiff < 1) return null; // Less than 1% difference is negligible
1077
+
1078
+ return `${percentDiff.toFixed(1)}% ${fasterQuery} faster`;
1079
+ },
1080
+
1081
+ updateHistoryCount(count) {
1082
+ document.querySelector('.history-count').textContent = `(${count})`;
1083
+ },
1084
+
1085
+ showHistoryError() {
1086
+ const loadingEl = document.querySelector('.history-loading');
1087
+ const itemsEl = document.querySelector('.history-items');
1088
+
1089
+ loadingEl.innerHTML = '<span style="color: #dc2626;">Failed to load query history</span>';
1090
+ itemsEl.style.display = 'none';
1091
+ },
1092
+
1093
+ showComparisonError(message) {
1094
+ document.getElementById('comparison-loading').style.display = 'none';
1095
+ document.getElementById('comparison-results').style.display = 'none';
1096
+ document.getElementById('comparison-empty').innerHTML = `
1097
+ <div class="empty-icon">⚠️</div>
1098
+ <h3>Comparison Failed</h3>
1099
+ <p>${message}</p>
1100
+ `;
1101
+ document.getElementById('comparison-empty').style.display = 'block';
1102
+ },
1103
+
1104
+ initializeCompareTab() {
1105
+ const compareTab = document.getElementById('compare-tab');
1106
+ if (!compareTab || compareTab.dataset.initialized) return;
1107
+
1108
+ compareTab.addEventListener('click', (e) => {
1109
+ e.preventDefault();
1110
+
1111
+ // Skip if disabled (let tooltip show help)
1112
+ if (compareTab.classList.contains('disabled')) return;
1113
+
1114
+ this.activateCompareTab();
1115
+ this.loadComparisonInterface();
1116
+ });
1117
+
1118
+ compareTab.dataset.initialized = 'true';
1119
+ }
1120
+ };
1121
+
1122
+ // Initialize the comparison system
1123
+ QueryComparison.init();
1124
+
1125
+ // Refresh history when a new analysis completes
1126
+ document.addEventListener('analysisCompleted', function() {
1127
+ QueryComparison.loadQueryHistory();
1128
+ });
1129
+ });