openclaw-smartmeter 0.3.0 → 0.4.1

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.
@@ -1,775 +1,1143 @@
1
- // SmartMeter Dashboard Application
2
- // Polls analysis.public.json every 5 seconds and updates the dashboard
1
+ /**
2
+ * SmartMeter Dashboard app.js
3
+ * Fully redesigned: sidebar nav, editable recommendations, modern UI
4
+ */
3
5
 
4
- let currentData = null;
6
+ /* ─── Globals ─── */
7
+ const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
8
+ let analysisData = null;
5
9
  let modelChart = null;
6
10
  let taskChart = null;
7
- let activeTab = 'usage';
8
- let refreshInterval = null;
9
- let openRouterConfigured = false;
10
- let openRouterUsage = null;
11
+ let autoRefreshTimer = null;
12
+ let editedRecommendations = {}; // keyed by index
13
+ let selectedModels = {}; // keyed by category, stores chosen model id
14
+ let budgetState = {}; // current budget control values
11
15
 
12
- // Initialize dashboard on page load
16
+ /* ─── Init ─── */
13
17
  document.addEventListener('DOMContentLoaded', () => {
14
- console.log('SmartMeter Dashboard loading...');
15
- initializeDashboard();
16
- startAutoRefresh();
17
- checkOpenRouterConfig();
18
+ initializeDashboard();
18
19
  });
19
20
 
20
- // Initialize the dashboard
21
21
  async function initializeDashboard() {
22
- try {
23
- await refreshDashboard();
24
- hideLoading();
25
- } catch (error) {
26
- console.error('Failed to initialize dashboard:', error);
27
- showToast('Failed to load dashboard data', 'error');
28
- hideLoading();
22
+ try {
23
+ // Try to load data from local file first (works when served statically)
24
+ await loadAnalysisData();
25
+ hideLoading();
26
+ checkOpenRouterConfig();
27
+ startAutoRefresh();
28
+ } catch (err) {
29
+ console.error('Init error:', err);
30
+ hideLoading();
31
+ showToast('Failed to load dashboard data');
32
+ }
33
+ }
34
+
35
+ /* ─── Data Loading ─── */
36
+ async function loadAnalysisData() {
37
+ try {
38
+ // First try the API
39
+ const res = await fetch(`${API_BASE_URL}/api/status`, { signal: AbortSignal.timeout(3000) });
40
+ if (res.ok) {
41
+ const json = await res.json();
42
+ if (json.success && json.analysis) {
43
+ analysisData = normalizeApiData(json.analysis);
44
+ renderAll();
45
+ return;
46
+ }
47
+ }
48
+ } catch {
49
+ // API not available — try local file
50
+ }
51
+
52
+ // Fallback: load public JSON
53
+ try {
54
+ const res = await fetch('analysis.public.json');
55
+ if (res.ok) {
56
+ analysisData = await res.json();
57
+ renderAll();
58
+ return;
29
59
  }
60
+ } catch {
61
+ // noop
62
+ }
63
+
64
+ console.warn('No analysis data available');
30
65
  }
31
66
 
32
- // Start auto-refresh every 5 seconds
33
- function startAutoRefresh() {
34
- refreshInterval = setInterval(async () => {
35
- try {
36
- await refreshDashboard(true); // Silent refresh
37
- } catch (error) {
38
- console.error('Auto-refresh failed:', error);
39
- }
40
- }, 5000);
67
+ function normalizeApiData(api) {
68
+ // Reshape API /status data → same shape as analysis.public.json
69
+ const s = api.summary || {};
70
+ const p = api.period || {};
71
+ return {
72
+ version: api.version || '0.1.0',
73
+ generated_at: new Date().toISOString(),
74
+ start_date: p.start || '--',
75
+ end_date: p.end || '--',
76
+ days_analyzed: p.days || 0,
77
+ confidence_level: s.confidence || api.confidence || 'Unknown',
78
+ total_tasks: s.totalTasks || 0,
79
+ daily_average: s.dailyAverage || 0,
80
+ monthly_projected_current: s.currentMonthlyCost || 0,
81
+ monthly_projected_optimized: s.optimizedMonthlyCost || 0,
82
+ cache_hit_rate: api.caching?.hitRate || 0,
83
+ model_breakdown: Object.entries(api.models || {}).map(([model, m]) => ({
84
+ model,
85
+ tasks: m.count || 0,
86
+ cost: m.cost || 0,
87
+ avg_cost_per_task: m.avgCostPerTask || 0,
88
+ percentage: 0 // calculated later
89
+ })),
90
+ task_breakdown: Object.entries(api.taskTypes || {}).map(([type, t]) => ({
91
+ type,
92
+ count: t.count || 0,
93
+ percentage: 0,
94
+ avg_cost: t.avgCost || 0
95
+ })),
96
+ recommendations: (api.recommendations || []).map(r => ({
97
+ type: r.type || 'general',
98
+ title: r.title,
99
+ description: r.description,
100
+ impact: r.impact || r.estimatedSavings || '--',
101
+ details: r.details || []
102
+ })),
103
+ cache_stats: api.caching || {},
104
+ cost_timeline: api.costTimeline || [],
105
+ model_alternatives: api.modelAlternatives || api.model_alternatives || [],
106
+ budget_defaults: api.budgetDefaults || api.budget_defaults || {},
107
+ warnings: api.warnings || []
108
+ };
41
109
  }
42
110
 
43
- // Refresh dashboard data
44
- async function refreshDashboard(silent = false) {
45
- try {
46
- if (!silent) {
47
- console.log('Fetching analysis data...');
48
- }
49
-
50
- const response = await fetch('./analysis.public.json');
51
- if (!response.ok) {
52
- throw new Error(`HTTP error! status: ${response.status}`);
53
- }
54
-
55
- const data = await response.json();
56
- currentData = data;
57
-
58
- // Update all dashboard components
59
- updateHeroCard(data);
60
- updateStatsCards(data);
61
- updateCharts(data);
62
- updateRecommendations(data);
63
- updateDetailsTab(data);
64
- updateLastUpdated();
65
-
66
- if (!silent) {
67
- console.log('Dashboard updated successfully');
68
- }
69
- } catch (error) {
70
- console.error('Error fetching data:', error);
71
- if (!silent) {
72
- showToast('Could not load analysis data. Make sure SmartMeter has run.', 'error');
73
- }
74
- }
111
+ /* ─── Render All ─── */
112
+ function renderAll() {
113
+ if (!analysisData) return;
114
+ const safe = fn => { try { fn(); } catch (e) { console.error(`[SmartMeter] ${fn.name} error:`, e); } };
115
+ safe(updateKPIs);
116
+ safe(updateMetrics);
117
+ safe(updateCharts);
118
+ safe(updateRecommendations);
119
+ safe(updateModelRecommendations);
120
+ safe(updateOtherRecommendations);
121
+ safe(updateBudgetControls);
122
+ safe(updateModelDetails);
123
+ safe(updateLastUpdated);
124
+ safe(checkCostDataNotice);
75
125
  }
76
126
 
77
- // Update hero card (savings display)
78
- function updateHeroCard(data) {
79
- const savings = data.monthly_projected_current - data.monthly_projected_optimized;
80
- const savingsPercent = ((savings / data.monthly_projected_current) * 100).toFixed(1);
81
-
82
- // Check if we have insufficient data for meaningful analysis
83
- const hasInsufficientData = isInsufficientData(data);
84
-
85
- // Show cost data notice if costs are zero but we have usage
86
- const costDataNotice = document.getElementById('costDataNotice');
87
- if (data.monthly_projected_current === 0 && data.total_tasks > 0) {
88
- costDataNotice.style.display = 'flex';
89
- } else {
90
- costDataNotice.style.display = 'none';
91
- }
92
-
93
- if (hasInsufficientData) {
94
- // Show professional message about needing more data
95
- document.getElementById('savingsAmount').textContent = '📊';
96
- document.getElementById('savingsPercentage').textContent = 'Analyzing...';
97
- document.getElementById('currentCost').innerHTML = '<span class="insufficient-data">Insufficient data</span>';
98
- document.getElementById('optimizedCost').innerHTML = '<span class="insufficient-data">Gathering usage...</span>';
99
-
100
- // Show helpful message in confidence badge
101
- const badge = document.getElementById('confidenceBadge');
102
- badge.innerHTML = `
103
- <span class="confidence-icon">💡</span>
104
- <span class="confidence-text">Need more usage data for accurate cost analysis (${data.total_tasks} tasks, ${data.days_analyzed} days so far)</span>
105
- `;
106
- } else {
107
- // Normal display with actual costs
108
- document.getElementById('savingsAmount').textContent = `$${savings.toFixed(2)}/mo`;
109
- document.getElementById('savingsPercentage').textContent = `${savingsPercent}% savings`;
110
- document.getElementById('currentCost').textContent = `$${data.monthly_projected_current.toFixed(2)}/mo`;
111
- document.getElementById('optimizedCost').textContent = `$${data.monthly_projected_optimized.toFixed(2)}/mo`;
112
-
113
- // Update confidence badge
114
- const badge = document.getElementById('confidenceBadge');
115
- const confidenceText = getConfidenceText(data.confidence_level, data.days_analyzed);
116
- badge.innerHTML = `
117
- <span class="confidence-icon">${getConfidenceIcon(data.confidence_level)}</span>
118
- <span class="confidence-text">${confidenceText}</span>
119
- `;
120
- }
127
+ /* ─── KPIs ─── */
128
+ function updateKPIs() {
129
+ const d = analysisData;
130
+ const current = d.monthly_projected_current || 0;
131
+ const optimized = d.monthly_projected_optimized || 0;
132
+ const savings = current - optimized;
133
+ const pct = current > 0 ? ((savings / current) * 100) : 0;
134
+
135
+ setText('currentCost', `$${current.toFixed(2)}<small>/mo</small>`);
136
+ setText('optimizedCost', `$${optimized.toFixed(2)}<small>/mo</small>`);
137
+ setText('savingsAmount', `$${savings.toFixed(2)}`);
138
+ setText('savingsPercentage', pct > 0 ? `↓ ${pct.toFixed(1)}% reduction` : '--');
121
139
  }
122
140
 
123
- // Update stats cards
124
- function updateStatsCards(data) {
125
- document.getElementById('analysisPeriod').textContent = `${data.days_analyzed} days`;
126
- document.getElementById('totalTasks').textContent = data.total_tasks;
127
- document.getElementById('cacheHitRate').textContent = `${(data.cache_hit_rate * 100).toFixed(1)}%`;
128
- document.getElementById('dailySpend').textContent = `$${data.daily_average.toFixed(2)}`;
141
+ /* ─── Metrics ─── */
142
+ function updateMetrics() {
143
+ const d = analysisData;
144
+ setText('analysisPeriod', `${d.days_analyzed || 0} days`);
145
+ setText('totalTasks', String(d.total_tasks || 0));
146
+ setText('cacheHitRate', `${((d.cache_hit_rate || 0) * 100).toFixed(1)}%`);
147
+ const daily = d.daily_average ? (d.daily_average * (d.monthly_projected_current / (d.total_tasks || 1))) : 0;
148
+ setText('dailySpend', `$${daily.toFixed(2)}`);
149
+
150
+ const conf = d.confidence_level || 'Unknown';
151
+ const badge = document.getElementById('confidenceBadge');
152
+ if (badge) {
153
+ badge.querySelector('.confidence-text').textContent = `Confidence: ${conf}`;
154
+ }
129
155
  }
130
156
 
131
- // Update Chart.js charts
132
- function updateCharts(data) {
133
- // Model Usage Chart
134
- if (modelChart) {
135
- modelChart.destroy();
136
- }
137
-
138
- const modelCtx = document.getElementById('modelChart').getContext('2d');
139
- const modelColors = [
140
- '#16a34a', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'
141
- ];
142
-
143
- modelChart = new Chart(modelCtx, {
144
- type: 'bar',
145
- data: {
146
- labels: data.model_breakdown.map(m => m.model),
147
- datasets: [{
148
- label: 'Cost ($)',
149
- data: data.model_breakdown.map(m => m.cost),
150
- backgroundColor: modelColors,
151
- borderRadius: 6
152
- }]
157
+ /* ─── Charts ─── */
158
+ function updateCharts() {
159
+ updateModelChart();
160
+ updateTaskChart();
161
+ }
162
+
163
+ function updateModelChart() {
164
+ const ctx = document.getElementById('modelChart')?.getContext('2d');
165
+ if (!ctx) return;
166
+
167
+ const models = analysisData.model_breakdown || [];
168
+ const labels = models.map(m => m.model);
169
+ const costData = models.map(m => m.cost);
170
+ const taskData = models.map(m => m.tasks);
171
+ const colors = ['#6366f1', '#22c55e', '#f59e0b', '#38bdf8', '#ef4444', '#a78bfa'];
172
+
173
+ if (modelChart) modelChart.destroy();
174
+ modelChart = new Chart(ctx, {
175
+ type: 'bar',
176
+ data: {
177
+ labels,
178
+ datasets: [
179
+ {
180
+ label: 'Cost ($)',
181
+ data: costData,
182
+ backgroundColor: colors.slice(0, labels.length).map(c => c + '55'),
183
+ borderColor: colors.slice(0, labels.length),
184
+ borderWidth: 1.5,
185
+ borderRadius: 6,
186
+ yAxisID: 'y'
153
187
  },
154
- options: {
155
- responsive: true,
156
- maintainAspectRatio: false,
157
- plugins: {
158
- legend: { display: false },
159
- tooltip: {
160
- callbacks: {
161
- afterLabel: function(context) {
162
- const model = data.model_breakdown[context.dataIndex];
163
- return [
164
- `Tasks: ${model.tasks}`,
165
- `Avg: $${model.avg_cost_per_task.toFixed(3)}`
166
- ];
167
- }
168
- }
169
- }
170
- },
171
- scales: {
172
- y: {
173
- beginAtZero: true,
174
- ticks: {
175
- callback: function(value) {
176
- return '$' + value.toFixed(2);
177
- }
178
- }
179
- }
180
- }
188
+ {
189
+ label: 'Tasks',
190
+ data: taskData,
191
+ type: 'line',
192
+ borderColor: '#38bdf8',
193
+ backgroundColor: 'rgba(56,189,248,.1)',
194
+ pointBackgroundColor: '#38bdf8',
195
+ pointRadius: 4,
196
+ tension: .3,
197
+ yAxisID: 'y1'
181
198
  }
182
- });
183
-
184
- // Task Classification Chart
185
- if (taskChart) {
186
- taskChart.destroy();
199
+ ]
200
+ },
201
+ options: {
202
+ responsive: true,
203
+ maintainAspectRatio: false,
204
+ interaction: { mode: 'index', intersect: false },
205
+ plugins: {
206
+ legend: { display: true, position: 'top', labels: { color: '#8b90a5', font: { size: 11 }, boxWidth: 12, padding: 12 } }
207
+ },
208
+ scales: {
209
+ x: { ticks: { color: '#5d6178', font: { size: 10, family: "'JetBrains Mono', monospace" } }, grid: { color: 'rgba(42,46,63,.4)' } },
210
+ y: { position: 'left', ticks: { color: '#8b90a5', font: { size: 10 }, callback: v => '$' + v.toFixed(2) }, grid: { color: 'rgba(42,46,63,.3)' } },
211
+ y1: { position: 'right', ticks: { color: '#38bdf8', font: { size: 10 } }, grid: { drawOnChartArea: false } }
212
+ }
187
213
  }
188
-
189
- const taskCtx = document.getElementById('taskChart').getContext('2d');
190
- const taskColors = ['#16a34a', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
191
-
192
- taskChart = new Chart(taskCtx, {
193
- type: 'doughnut',
194
- data: {
195
- labels: data.task_breakdown.map(t => t.type),
196
- datasets: [{
197
- data: data.task_breakdown.map(t => t.count),
198
- backgroundColor: taskColors,
199
- borderWidth: 2,
200
- borderColor: '#fff'
201
- }]
202
- },
203
- options: {
204
- responsive: true,
205
- maintainAspectRatio: false,
206
- plugins: {
207
- legend: {
208
- position: 'right',
209
- labels: {
210
- padding: 15,
211
- font: { size: 13 }
212
- }
213
- },
214
- tooltip: {
215
- callbacks: {
216
- label: function(context) {
217
- const task = data.task_breakdown[context.dataIndex];
218
- const percentage = ((task.count / data.total_tasks) * 100).toFixed(1);
219
- return `${task.type}: ${task.count} (${percentage}%)`;
220
- }
221
- }
222
- }
223
- }
224
- }
225
- });
214
+ });
226
215
  }
227
216
 
228
- // Update recommendations section
229
- function updateRecommendations(data) {
230
- const container = document.getElementById('recommendationsList');
231
-
232
- if (!data.recommendations || data.recommendations.length === 0) {
233
- container.innerHTML = '<p style="color: var(--text-secondary);">No recommendations available yet. Run analysis again after collecting more data.</p>';
234
- return;
217
+ function updateTaskChart() {
218
+ const ctx = document.getElementById('taskChart')?.getContext('2d');
219
+ if (!ctx) return;
220
+
221
+ const tasks = analysisData.task_breakdown || [];
222
+ const labels = tasks.map(t => t.type);
223
+ const data = tasks.map(t => t.count);
224
+ const colors = ['#6366f1', '#22c55e', '#f59e0b', '#38bdf8', '#ef4444'];
225
+
226
+ if (taskChart) taskChart.destroy();
227
+ taskChart = new Chart(ctx, {
228
+ type: 'doughnut',
229
+ data: {
230
+ labels,
231
+ datasets: [{
232
+ data,
233
+ backgroundColor: colors.slice(0, labels.length),
234
+ borderColor: '#1c1f2e',
235
+ borderWidth: 3,
236
+ hoverOffset: 6
237
+ }]
238
+ },
239
+ options: {
240
+ responsive: true,
241
+ maintainAspectRatio: false,
242
+ cutout: '60%',
243
+ plugins: {
244
+ legend: { position: 'bottom', labels: { color: '#8b90a5', font: { size: 11 }, padding: 14, boxWidth: 12 } }
245
+ }
235
246
  }
236
-
237
- container.innerHTML = data.recommendations.map((rec, index) => `
238
- <div class="recommendation-item">
239
- <div class="recommendation-header">
240
- <div class="recommendation-title">
241
- <span>${getRecommendationIcon(rec.type)}</span>
242
- <span>${rec.title}</span>
243
- </div>
244
- <div class="recommendation-impact">Saves ${rec.impact}</div>
245
- </div>
246
- <div class="recommendation-description">${rec.description}</div>
247
- ${rec.details ? `
248
- <div class="recommendation-details">
249
- ${rec.details.map(d => `<span>• ${d}</span>`).join('')}
250
- </div>
251
- ` : ''}
252
- <div class="recommendation-actions">
253
- <button class="btn btn-secondary btn-sm" onclick="viewRecommendationDetails(${index})">
254
- View Details
255
- </button>
256
- </div>
257
- </div>
258
- `).join('');
247
+ });
259
248
  }
260
249
 
261
- // Update details tab content
262
- function updateDetailsTab(data) {
263
- switchTab(activeTab);
250
+ /* ─── Recommendations (Savings Banner) ─── */
251
+ function updateRecommendations() {
252
+ const d = analysisData;
253
+ if (!d) return;
254
+ const current = d.monthly_projected_current || 0;
255
+ const optimized = computeOptimizedCost();
256
+ const savings = current - optimized;
257
+
258
+ setText('totalSavingsEstimate', `$${savings.toFixed(2)}/mo`);
259
+ setText('bannerCurrentCost', `$${current.toFixed(2)}`);
260
+ setText('bannerOptimizedCost', `$${optimized.toFixed(2)}`);
264
261
  }
265
262
 
266
- // Switch between detail tabs
267
- function switchTab(tab) {
268
- activeTab = tab;
269
-
270
- // Update active button
271
- document.querySelectorAll('.tab-btn').forEach(btn => {
272
- btn.classList.remove('active');
273
- });
274
- event?.target?.classList.add('active');
275
-
276
- const content = document.getElementById('detailsContent');
277
-
278
- if (!currentData) {
279
- content.innerHTML = '<p>No data available</p>';
280
- return;
263
+ function computeOptimizedCost() {
264
+ const d = analysisData;
265
+ if (!d) return 0;
266
+ const alternatives = d.model_alternatives || [];
267
+ const monthlyCurrent = d.monthly_projected_current || 0;
268
+ const totalTasks = d.total_tasks || 0;
269
+
270
+ // Estimate tier distribution
271
+ const tierDist = { high: 0.10, medium: 0.55, low: 0.35 };
272
+ const tasks = d.task_breakdown || [];
273
+ if (tasks.length > 0) {
274
+ const total = tasks.reduce((s, t) => s + t.count, 0) || 1;
275
+ const research = tasks.filter(t => /research/i.test(t.type)).reduce((s, t) => s + t.count, 0);
276
+ const code = tasks.filter(t => /code/i.test(t.type)).reduce((s, t) => s + t.count, 0);
277
+ const writing = tasks.filter(t => /writ/i.test(t.type)).reduce((s, t) => s + t.count, 0);
278
+ tierDist.high = Math.max(0.05, (research * 1.5 + code * 0.1) / total);
279
+ tierDist.medium = Math.max(0.20, (code * 0.6 + writing * 0.7) / total);
280
+ tierDist.low = Math.max(0.10, 1 - tierDist.high - tierDist.medium);
281
+ }
282
+
283
+ // Current cost per tier
284
+ const models = d.model_breakdown || [];
285
+ const modelsSortedByCost = [...models].sort((a, b) => b.avg_cost_per_task - a.avg_cost_per_task);
286
+ const currentModels = {
287
+ high: modelsSortedByCost[0] || null,
288
+ medium: modelsSortedByCost[1] || modelsSortedByCost[0] || null,
289
+ low: modelsSortedByCost[modelsSortedByCost.length - 1] || null
290
+ };
291
+
292
+ let total = 0;
293
+ for (const tier of TASK_TIERS) {
294
+ const tierTasks = Math.round(totalTasks * tierDist[tier.id]);
295
+ const chosenId = selectedModels[tier.id];
296
+ if (chosenId) {
297
+ const alt = alternatives.find(a => a.id === chosenId);
298
+ if (alt) {
299
+ total += tierTasks * alt.avg_cost_per_task;
300
+ continue;
301
+ }
281
302
  }
282
-
283
- switch(tab) {
284
- case 'usage':
285
- content.innerHTML = generateUsageDetails(currentData);
286
- break;
287
- case 'models':
288
- content.innerHTML = generateModelDetails(currentData);
289
- break;
290
- case 'timeline':
291
- content.innerHTML = generateTimelineDetails(currentData);
292
- break;
303
+ // No selection — use current model cost for this tier
304
+ const cm = currentModels[tier.id];
305
+ if (cm) {
306
+ total += tierTasks * cm.avg_cost_per_task;
307
+ } else {
308
+ total += monthlyCurrent * tierDist[tier.id];
293
309
  }
310
+ }
311
+
312
+ // Apply cache savings if applicable
313
+ const cacheBoost = (d.cache_hit_rate || 0) < 0.5 ? 0.85 : 1;
314
+ return total * cacheBoost;
294
315
  }
295
316
 
296
- // Generate usage details HTML
297
- function generateUsageDetails(data) {
317
+ /* ─── Task Complexity Tiers ─── */
318
+ const TASK_TIERS = [
319
+ {
320
+ id: 'high',
321
+ label: 'High-Complexity Tasks',
322
+ icon: '🧠',
323
+ description: 'Architecture design, complex reasoning, multi-step research, difficult debugging. These tasks need the highest-quality models.',
324
+ color: 'var(--red)',
325
+ colorSubtle: 'var(--red-subtle)',
326
+ borderColor: 'rgba(239,68,68,.25)',
327
+ sort: (a, b) => b.quality_score - a.quality_score // quality first
328
+ },
329
+ {
330
+ id: 'medium',
331
+ label: 'Medium-Complexity Tasks',
332
+ icon: '⚙️',
333
+ description: 'Standard coding, writing, code reviews, feature implementation. A good balance of quality and cost.',
334
+ color: 'var(--amber)',
335
+ colorSubtle: 'var(--amber-subtle)',
336
+ borderColor: 'rgba(245,158,11,.25)',
337
+ sort: (a, b) => (b.quality_score * 0.5 - b.avg_cost_per_task * 100) - (a.quality_score * 0.5 - a.avg_cost_per_task * 100) // value
338
+ },
339
+ {
340
+ id: 'low',
341
+ label: 'Low-Complexity Tasks',
342
+ icon: '⚡',
343
+ description: 'Simple queries, quick fixes, boilerplate generation, formatting. Speed and low cost matter most.',
344
+ color: 'var(--green)',
345
+ colorSubtle: 'var(--green-subtle)',
346
+ borderColor: 'rgba(34,197,94,.25)',
347
+ sort: (a, b) => a.avg_cost_per_task - b.avg_cost_per_task // cheapest first
348
+ }
349
+ ];
350
+
351
+ /* ─── Model Recommendations by Tier ─── */
352
+ function updateModelRecommendations() {
353
+ const container = document.getElementById('modelRecommendationsList');
354
+ if (!container) return;
355
+
356
+ const d = analysisData;
357
+ const alternatives = d.model_alternatives || [];
358
+ const totalTasks = d.total_tasks || 0;
359
+ const monthlyCurrent = d.monthly_projected_current || 0;
360
+
361
+ if (alternatives.length === 0) {
362
+ container.innerHTML = '<div class="empty-state"><p>No model data available. Connect your OpenRouter API key to see recommendations.</p></div>';
363
+ return;
364
+ }
365
+
366
+ // Estimate task distribution across tiers
367
+ const tierDistribution = { high: 0.10, medium: 0.55, low: 0.35 };
368
+ // Refine from task_breakdown if available
369
+ const tasks = d.task_breakdown || [];
370
+ if (tasks.length > 0) {
371
+ const total = tasks.reduce((s, t) => s + t.count, 0) || 1;
372
+ const research = tasks.filter(t => /research/i.test(t.type)).reduce((s, t) => s + t.count, 0);
373
+ const code = tasks.filter(t => /code/i.test(t.type)).reduce((s, t) => s + t.count, 0);
374
+ const writing = tasks.filter(t => /writ/i.test(t.type)).reduce((s, t) => s + t.count, 0);
375
+ tierDistribution.high = Math.max(0.05, (research * 1.5 + code * 0.1) / total);
376
+ tierDistribution.medium = Math.max(0.20, (code * 0.6 + writing * 0.7) / total);
377
+ tierDistribution.low = Math.max(0.10, 1 - tierDistribution.high - tierDistribution.medium);
378
+ }
379
+
380
+ // Identify which model is currently used for each tier (best guess from model_breakdown)
381
+ const models = d.model_breakdown || [];
382
+ const modelsSortedByCost = [...models].sort((a, b) => b.avg_cost_per_task - a.avg_cost_per_task);
383
+ const currentPerTier = {
384
+ high: modelsSortedByCost[0]?.model || null, // most expensive = high
385
+ medium: modelsSortedByCost[1]?.model || modelsSortedByCost[0]?.model || null,
386
+ low: modelsSortedByCost[modelsSortedByCost.length - 1]?.model || null // cheapest = low
387
+ };
388
+
389
+ const cards = TASK_TIERS.map((tier, tierIdx) => {
390
+ const chosenId = selectedModels[tier.id] || null;
391
+ const tierTasks = Math.round(totalTasks * tierDistribution[tier.id]);
392
+ const tierCostShare = monthlyCurrent * tierDistribution[tier.id];
393
+ const currentModel = currentPerTier[tier.id];
394
+ const currentAlt = alternatives.find(a => a.name === currentModel);
395
+ const currentCostPerTask = currentAlt?.avg_cost_per_task || (tierCostShare / (tierTasks || 1));
396
+
397
+ // Sort alternatives by tier preference
398
+ const sorted = [...alternatives].sort(tier.sort);
399
+
400
+ // Build options with projected costs
401
+ const opts = sorted.map((alt, rank) => {
402
+ const projCost = tierTasks * alt.avg_cost_per_task;
403
+ const currentTierCost = tierTasks * currentCostPerTask;
404
+ const savings = currentTierCost - projCost;
405
+ const isCurrent = alt.name === currentModel;
406
+ const isChosen = chosenId === alt.id;
407
+ const isRecommended = rank === 0 && !isCurrent;
408
+ return { ...alt, projCost, savings, isCurrent, isChosen, isRecommended };
409
+ });
410
+
411
+ const chosen = opts.find(o => o.isChosen);
412
+ const chosenSavings = chosen ? chosen.savings : 0;
413
+
298
414
  return `
299
- <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
300
- <div>
301
- <h4 style="margin-bottom: 12px; color: var(--text-primary);">Cost Breakdown</h4>
302
- <table style="width: 100%; font-size: 14px;">
303
- <tr><td>Daily Average:</td><td style="text-align: right; font-weight: 600;">$${data.daily_average.toFixed(2)}</td></tr>
304
- <tr><td>Weekly Projected:</td><td style="text-align: right; font-weight: 600;">$${data.weekly_projected.toFixed(2)}</td></tr>
305
- <tr><td>Monthly Projected:</td><td style="text-align: right; font-weight: 600;">$${data.monthly_projected_current.toFixed(2)}</td></tr>
306
- <tr style="border-top: 2px solid var(--border-color); font-weight: 700; color: var(--success-color);">
307
- <td style="padding-top: 8px;">Optimized Monthly:</td>
308
- <td style="text-align: right; padding-top: 8px;">$${data.monthly_projected_optimized.toFixed(2)}</td>
309
- </tr>
310
- </table>
311
- </div>
312
- <div>
313
- <h4 style="margin-bottom: 12px; color: var(--text-primary);">Performance Metrics</h4>
314
- <table style="width: 100%; font-size: 14px;">
315
- <tr><td>Cache Hit Rate:</td><td style="text-align: right; font-weight: 600;">${(data.cache_hit_rate * 100).toFixed(1)}%</td></tr>
316
- <tr><td>Total Tasks:</td><td style="text-align: right; font-weight: 600;">${data.total_tasks}</td></tr>
317
- <tr><td>Analysis Period:</td><td style="text-align: right; font-weight: 600;">${data.days_analyzed} days</td></tr>
318
- <tr><td>Confidence Level:</td><td style="text-align: right; font-weight: 600;">${data.confidence_level}</td></tr>
319
- </table>
415
+ <div class="model-rec-card ${chosenId ? 'has-selection' : ''}" style="border-color: ${tier.borderColor}">
416
+ <div class="model-rec-header">
417
+ <div class="model-rec-current">
418
+ <div class="tier-badge" style="background: ${tier.colorSubtle}; color: ${tier.color}">
419
+ <span>${tier.icon}</span> ${escHtml(tier.label)}
320
420
  </div>
421
+ <div class="model-rec-tier-desc">${escHtml(tier.description)}</div>
422
+ <div class="model-rec-stats">~${tierTasks} tasks/mo · ~$${tierCostShare.toFixed(2)}/mo${currentModel ? ' · Currently: ' + escHtml(currentModel) : ''}</div>
423
+ </div>
424
+ ${chosenSavings > 0 ? `
425
+ <div class="model-rec-savings-badge">
426
+ <span class="savings-arrow">↓</span> $${chosenSavings.toFixed(2)}/mo
427
+ </div>` : ''}
321
428
  </div>
322
- `;
323
- }
324
429
 
325
- // Generate model details HTML
326
- function generateModelDetails(data) {
327
- return `
328
- <div>
329
- <h4 style="margin-bottom: 16px; color: var(--text-primary);">Model Usage Breakdown</h4>
330
- <table style="width: 100%; font-size: 14px; border-collapse: collapse;">
331
- <thead>
332
- <tr style="background: var(--bg-tertiary); text-align: left;">
333
- <th style="padding: 12px;">Model</th>
334
- <th style="padding: 12px; text-align: right;">Tasks</th>
335
- <th style="padding: 12px; text-align: right;">Total Cost</th>
336
- <th style="padding: 12px; text-align: right;">Avg/Task</th>
337
- <th style="padding: 12px; text-align: right;">% of Total</th>
338
- </tr>
339
- </thead>
340
- <tbody>
341
- ${data.model_breakdown.map(model => `
342
- <tr style="border-bottom: 1px solid var(--border-color);">
343
- <td style="padding: 12px; font-weight: 500;">${model.model}</td>
344
- <td style="padding: 12px; text-align: right;">${model.tasks}</td>
345
- <td style="padding: 12px; text-align: right;">$${model.cost.toFixed(2)}</td>
346
- <td style="padding: 12px; text-align: right;">$${model.avg_cost_per_task.toFixed(3)}</td>
347
- <td style="padding: 12px; text-align: right;">${model.percentage.toFixed(1)}%</td>
348
- </tr>
349
- `).join('')}
350
- </tbody>
351
- </table>
430
+ <div class="model-alt-label">Select a model for this tier:</div>
431
+ <div class="model-alt-list">
432
+ ${opts.map(alt => `
433
+ <label class="model-alt-option ${alt.isChosen ? 'selected' : ''} ${alt.isCurrent ? 'current' : ''}" onclick="selectModelAlternative('${tier.id}', '${alt.id}')">
434
+ <div class="model-alt-radio">
435
+ <input type="radio" name="tier-${tierIdx}" ${alt.isChosen || (!chosenId && alt.isCurrent) ? 'checked' : ''}>
436
+ </div>
437
+ <div class="model-alt-info">
438
+ <div class="model-alt-name">
439
+ ${escHtml(alt.name)}
440
+ ${alt.isCurrent ? '<span class="model-tag current-tag">CURRENT</span>' : ''}
441
+ ${alt.isRecommended ? '<span class="model-tag best-tag">RECOMMENDED</span>' : ''}
442
+ </div>
443
+ <div class="model-alt-meta">
444
+ Quality: ${renderQualityDots(alt.quality_score)} · ${escHtml(alt.speed || '')} · Best for: ${(alt.best_for || []).join(', ')}
445
+ </div>
446
+ </div>
447
+ <div class="model-alt-cost">
448
+ <div class="model-alt-price">$${alt.projCost.toFixed(2)}<small>/mo</small></div>
449
+ ${!alt.isCurrent ? `<div class="model-alt-delta ${alt.savings > 0 ? 'delta-positive' : alt.savings < 0 ? 'delta-negative' : ''}">
450
+ ${alt.savings > 0 ? '↓' : alt.savings < 0 ? '↑' : '='} ${alt.savings !== 0 ? '$' + Math.abs(alt.savings).toFixed(2) : 'same'}
451
+ </div>` : '<div class="model-alt-delta">—</div>'}
452
+ </div>
453
+ </label>
454
+ `).join('')}
352
455
  </div>
353
- `;
456
+ </div>`;
457
+ });
458
+
459
+ container.innerHTML = cards.join('');
460
+ }
461
+
462
+ function renderQualityDots(score) {
463
+ const filled = Math.round(score / 20);
464
+ let dots = '';
465
+ for (let i = 0; i < 5; i++) {
466
+ dots += `<span class="quality-dot ${i < filled ? 'filled' : ''}"></span>`;
467
+ }
468
+ return `<span class="quality-dots">${dots}</span> ${score}`;
469
+ }
470
+
471
+ function selectModelAlternative(tierId, modelId) {
472
+ // Toggle: if clicking already-chosen, deselect
473
+ if (selectedModels[tierId] === modelId) {
474
+ delete selectedModels[tierId];
475
+ } else {
476
+ selectedModels[tierId] = modelId;
477
+ }
478
+ updateModelRecommendations();
479
+ updateRecommendations(); // recalculate savings banner
354
480
  }
355
481
 
356
- // Generate timeline details HTML
357
- function generateTimelineDetails(data) {
482
+ /* ─── Other Recommendations (non-model) ─── */
483
+ function updateOtherRecommendations() {
484
+ const container = document.getElementById('otherRecommendationsList');
485
+ if (!container) return;
486
+
487
+ const recs = (analysisData.recommendations || []).filter(r => r.type !== 'model_switch' && r.type !== 'budget_control');
488
+ if (recs.length === 0) {
489
+ container.innerHTML = '<div class="empty-state"><p>No additional recommendations.</p></div>';
490
+ return;
491
+ }
492
+
493
+ container.innerHTML = recs.map((rec, i) => {
494
+ const edited = editedRecommendations[i];
495
+ const desc = edited?.description ?? rec.description;
496
+ const impact = rec.impact || '--';
497
+ const impactClass = getImpactClass(impact);
498
+ const isSelected = edited?.selected || false;
499
+ const isEditing = edited?.editing || false;
500
+ const isExpanded = edited?.expanded || false;
501
+
358
502
  return `
359
- <div>
360
- <h4 style="margin-bottom: 16px; color: var(--text-primary);">Cost Timeline</h4>
361
- <p style="color: var(--text-secondary); font-size: 14px;">
362
- Analysis Period: ${data.start_date} to ${data.end_date}
363
- </p>
364
- <div style="margin-top: 20px;">
365
- <canvas id="timelineChart" style="max-height: 300px;"></canvas>
503
+ <div class="rec-card ${isSelected ? 'selected' : ''} ${isEditing ? 'editing' : ''} ${isExpanded ? 'expanded' : ''}" data-index="${i}">
504
+ <div class="rec-header">
505
+ <input type="checkbox" class="rec-checkbox" ${isSelected ? 'checked' : ''} onchange="toggleRecSelection(${i})" title="Select for apply">
506
+ <div class="rec-title-wrap" onclick="toggleRecExpand(${i})">
507
+ <div class="rec-title">${escHtml(rec.title)}</div>
508
+ <div class="rec-meta">${escHtml(rec.type)} · Impact: ${escHtml(impact)}</div>
509
+ </div>
510
+ <span class="rec-impact-badge ${impactClass}">${escHtml(impact)}</span>
511
+ <div class="rec-actions">
512
+ <button class="btn btn-sm btn-outline" onclick="toggleRecEdit(${i})" title="Edit recommendation">✏️</button>
513
+ </div>
514
+ </div>
515
+ <div class="rec-body">
516
+ <p class="rec-description">${escHtml(desc)}</p>
517
+ <div class="rec-edit-area">
518
+ <textarea class="rec-edit-textarea" id="recEdit${i}" oninput="recDirty(${i})">${escHtml(desc)}</textarea>
519
+ <div class="rec-edit-actions">
520
+ <button class="btn btn-sm btn-success" onclick="saveRecEdit(${i})">Save</button>
521
+ <button class="btn btn-sm btn-ghost" onclick="cancelRecEdit(${i})">Cancel</button>
366
522
  </div>
523
+ </div>
524
+ ${renderRecDetails(rec.details || [])}
367
525
  </div>
368
- `;
369
- }
370
-
371
- // Helper functions
372
- function isInsufficientData(data) {
373
- // Insufficient data if:
374
- // 1. Total costs are zero or near-zero (< $0.01)
375
- // 2. Less than 5 tasks analyzed
376
- // 3. Less than 1 day of data
377
- const hasNoCosts = data.monthly_projected_current < 0.01;
378
- const hasMinimalTasks = data.total_tasks < 5;
379
- const hasMinimalDays = data.days_analyzed < 1;
380
-
381
- return hasNoCosts || hasMinimalTasks || hasMinimalDays;
382
- }
383
-
384
- function getConfidenceIcon(level) {
385
- const icons = {
386
- 'high': '✅',
387
- 'medium': '⚠️',
388
- 'low': 'ℹ️',
389
- 'optimistic': '📊'
390
- };
391
- return icons[level.toLowerCase()] || 'ℹ️';
526
+ </div>`;
527
+ }).join('');
392
528
  }
393
529
 
394
- function getConfidenceText(level, days) {
395
- if (days < 7) {
396
- return `${level} confidence (${days} days analyzed - need 14+ for higher confidence)`;
397
- }
398
- return `${level} confidence (${days} days analyzed)`;
399
- }
530
+ /* ─── Budget Controls ─── */
531
+ function updateBudgetControls() {
532
+ const d = analysisData;
533
+ if (!d) return;
534
+ const defaults = d.budget_defaults || {};
535
+ const budgetRec = (d.recommendations || []).find(r => r.type === 'budget_control');
400
536
 
401
- function getRecommendationIcon(type) {
402
- const icons = {
403
- 'model_switch': '🔄',
404
- 'agent_creation': '🤖',
405
- 'cache_optimization': '',
406
- 'budget_control': '💰',
407
- 'skill_optimization': '⚙️'
537
+ // Initialize budget state from defaults or recommendation data
538
+ if (!budgetState.initialized) {
539
+ budgetState = {
540
+ initialized: true,
541
+ dailyCap: defaults.daily_cap || parseFloat(extractBudgetValue(budgetRec, 'Daily cap')) || 2.00,
542
+ weeklyAlert: defaults.weekly_alert_pct || 75,
543
+ monthly: defaults.monthly_budget || parseFloat(extractBudgetValue(budgetRec, 'Monthly budget')) || 40,
544
+ autoPause: defaults.auto_pause_pct || 95
408
545
  };
409
- return icons[type] || '💡';
546
+ }
547
+
548
+ // Set input values
549
+ setInputVal('budgetDailyCap', budgetState.dailyCap);
550
+ setInputVal('budgetDailyCapSlider', budgetState.dailyCap);
551
+ setInputVal('budgetWeeklyAlert', budgetState.weeklyAlert);
552
+ setInputVal('budgetWeeklyAlertSlider', budgetState.weeklyAlert);
553
+ setInputVal('budgetMonthly', budgetState.monthly);
554
+ setInputVal('budgetMonthlySlider', budgetState.monthly);
555
+ setInputVal('budgetAutoPause', budgetState.autoPause);
556
+ setInputVal('budgetAutoPauseSlider', budgetState.autoPause);
557
+
558
+ updateBudgetImpact();
410
559
  }
411
560
 
412
- function updateLastUpdated() {
413
- const now = new Date();
414
- const timeString = now.toLocaleTimeString('en-US', {
415
- hour: '2-digit',
416
- minute: '2-digit',
417
- second: '2-digit'
418
- });
419
- document.getElementById('lastUpdated').textContent = timeString;
561
+ function extractBudgetValue(rec, prefix) {
562
+ if (!rec || !rec.details) return '';
563
+ const detail = rec.details.find(d => d.startsWith(prefix));
564
+ if (!detail) return '';
565
+ const match = detail.match(/\$?([\d.]+)/);
566
+ return match ? match[1] : '';
420
567
  }
421
568
 
422
- function hideLoading() {
423
- const overlay = document.getElementById('loadingOverlay');
424
- overlay.classList.add('hidden');
569
+ function setInputVal(id, val) {
570
+ const el = document.getElementById(id);
571
+ if (el) el.value = val;
425
572
  }
426
573
 
427
- function showToast(message, type = 'info') {
428
- const toast = document.getElementById('toast');
429
- toast.textContent = message;
430
- toast.className = `toast show ${type}`;
431
-
432
- setTimeout(() => {
433
- toast.classList.remove('show');
434
- }, 4000);
574
+ function syncBudgetSlider(inputId, value) {
575
+ const input = document.getElementById(inputId);
576
+ if (input) input.value = value;
577
+ onBudgetChange();
435
578
  }
436
579
 
437
- // API Configuration
438
- const API_BASE_URL = 'http://localhost:3001';
580
+ function onBudgetChange() {
581
+ const daily = parseFloat(document.getElementById('budgetDailyCap')?.value) || 0;
582
+ const weekly = parseFloat(document.getElementById('budgetWeeklyAlert')?.value) || 0;
583
+ const monthly = parseFloat(document.getElementById('budgetMonthly')?.value) || 0;
584
+ const autoPause = parseFloat(document.getElementById('budgetAutoPause')?.value) || 0;
585
+
586
+ budgetState.dailyCap = daily;
587
+ budgetState.weeklyAlert = weekly;
588
+ budgetState.monthly = monthly;
589
+ budgetState.autoPause = autoPause;
439
590
 
440
- // Action handlers
441
- function viewRecommendationDetails(index) {
442
- const rec = currentData.recommendations[index];
443
- alert(`Recommendation Details:\n\n${rec.title}\n\n${rec.description}`);
444
- // TODO: Open modal with full details
591
+ // Sync sliders
592
+ setInputVal('budgetDailyCapSlider', daily);
593
+ setInputVal('budgetWeeklyAlertSlider', weekly);
594
+ setInputVal('budgetMonthlySlider', monthly);
595
+ setInputVal('budgetAutoPauseSlider', autoPause);
596
+
597
+ updateBudgetImpact();
445
598
  }
446
599
 
447
- async function exportReport() {
448
- try {
449
- showToast('Exporting report...', 'info');
450
- const response = await fetch(`${API_BASE_URL}/api/export`);
451
-
452
- if (!response.ok) {
453
- throw new Error('Failed to export report');
454
- }
455
-
456
- const blob = await response.blob();
457
- const url = window.URL.createObjectURL(blob);
458
- const a = document.createElement('a');
459
- a.href = url;
460
- a.download = `smartmeter-report-${new Date().toISOString().slice(0,10)}.md`;
461
- document.body.appendChild(a);
462
- a.click();
463
- window.URL.revokeObjectURL(url);
464
- document.body.removeChild(a);
465
-
466
- showToast('Report exported successfully!', 'success');
467
- } catch (error) {
468
- console.error('Export failed:', error);
469
- showToast('Failed to export report. Make sure the API server is running.', 'error');
470
- }
600
+ function updateBudgetImpact() {
601
+ const grid = document.getElementById('budgetImpactGrid');
602
+ if (!grid) return;
603
+
604
+ const d = analysisData;
605
+ const current = d?.monthly_projected_current || 0;
606
+ const daily = budgetState.dailyCap || 0;
607
+ const monthly = budgetState.monthly || 0;
608
+ const weeklyPct = budgetState.weeklyAlert || 75;
609
+ const autoPausePct = budgetState.autoPause || 95;
610
+
611
+ const weeklyBudget = monthly / 4.33;
612
+ const weeklyAlertAt = weeklyBudget * (weeklyPct / 100);
613
+ const autoPauseAt = monthly * (autoPausePct / 100);
614
+ const maxDailySpend = daily * 30;
615
+ const effectiveCap = Math.min(monthly, maxDailySpend);
616
+ const headroom = effectiveCap - current;
617
+
618
+ grid.innerHTML = `
619
+ <div class="impact-item">
620
+ <div class="impact-label">Max daily spend</div>
621
+ <div class="impact-value">$${daily.toFixed(2)}</div>
622
+ </div>
623
+ <div class="impact-item">
624
+ <div class="impact-label">Weekly alert at</div>
625
+ <div class="impact-value">$${weeklyAlertAt.toFixed(2)} <small>(${weeklyPct}%)</small></div>
626
+ </div>
627
+ <div class="impact-item">
628
+ <div class="impact-label">Auto-pause at</div>
629
+ <div class="impact-value">$${autoPauseAt.toFixed(2)} <small>(${autoPausePct}%)</small></div>
630
+ </div>
631
+ <div class="impact-item ${headroom >= 0 ? 'impact-safe' : 'impact-warning'}">
632
+ <div class="impact-label">Budget headroom</div>
633
+ <div class="impact-value">${headroom >= 0 ? '+' : ''}$${headroom.toFixed(2)}/mo</div>
634
+ </div>
635
+ `;
471
636
  }
472
637
 
473
- async function viewConfig() {
474
- try {
475
- showToast('Loading config preview...', 'info');
476
- const response = await fetch(`${API_BASE_URL}/api/preview`);
477
-
478
- if (!response.ok) {
479
- throw new Error('Failed to load preview');
480
- }
481
-
482
- const data = await response.json();
483
-
484
- if (data.success) {
485
- // Show config in a modal or new window
486
- const configWindow = window.open('', 'Config Preview', 'width=800,height=600');
487
- configWindow.document.write(`
488
- <html>
489
- <head>
490
- <title>SmartMeter Config Preview</title>
491
- <style>
492
- body { font-family: monospace; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
493
- pre { background: #252526; padding: 15px; border-radius: 5px; overflow: auto; }
494
- h2 { color: #4ec9b0; }
495
- </style>
496
- </head>
497
- <body>
498
- <h2>📋 Optimized Configuration Preview</h2>
499
- <p>This is the configuration that will be applied to ~/.openclaw/openclaw.json</p>
500
- <pre>${JSON.stringify(data.config, null, 2)}</pre>
501
- </body>
502
- </html>
503
- `);
504
- showToast('Config preview opened in new window', 'success');
505
- } else {
506
- showToast(data.error || 'Failed to load preview', 'error');
638
+ function resetBudgetDefaults() {
639
+ const d = analysisData;
640
+ const defaults = d?.budget_defaults || {};
641
+ budgetState = {
642
+ initialized: true,
643
+ dailyCap: defaults.daily_cap || 2.40,
644
+ weeklyAlert: defaults.weekly_alert_pct || 75,
645
+ monthly: defaults.monthly_budget || 40,
646
+ autoPause: defaults.auto_pause_pct || 95
647
+ };
648
+ updateBudgetControls();
649
+ showToast('Budget controls reset to defaults');
650
+ }
651
+
652
+ async function applyBudgetControls() {
653
+ try {
654
+ const res = await fetch(`${API_BASE_URL}/api/apply`, {
655
+ method: 'POST',
656
+ headers: { 'Content-Type': 'application/json' },
657
+ body: JSON.stringify({
658
+ confirm: true,
659
+ budget: {
660
+ daily: budgetState.dailyCap,
661
+ weekly_alert_pct: budgetState.weeklyAlert,
662
+ monthly: budgetState.monthly,
663
+ auto_pause_pct: budgetState.autoPause
507
664
  }
508
- } catch (error) {
509
- console.error('Preview failed:', error);
510
- showToast('Failed to load preview. Make sure the API server is running.', 'error');
665
+ })
666
+ });
667
+ const json = await res.json();
668
+ if (json.success) {
669
+ showToast('✅ Budget controls saved!');
670
+ } else {
671
+ showToast(`Error: ${json.error || 'Save failed'}`);
511
672
  }
673
+ } catch {
674
+ // Offline mode — save to local state
675
+ showToast('✅ Budget controls saved locally (API offline)');
676
+ }
512
677
  }
513
678
 
514
- async function applyOptimizations() {
515
- if (!confirm('Apply all optimizations?\n\nThis will:\n• Create a backup of your current config\n• Apply the optimized configuration\n• Restart OpenClaw may be required\n\nContinue?')) {
516
- return;
517
- }
518
-
519
- try {
520
- showToast('Applying optimizations...', 'info');
521
-
522
- const response = await fetch(`${API_BASE_URL}/api/apply`, {
523
- method: 'POST',
524
- headers: {
525
- 'Content-Type': 'application/json'
526
- },
527
- body: JSON.stringify({ confirm: true })
528
- });
529
-
530
- if (!response.ok) {
531
- throw new Error('Failed to apply optimizations');
532
- }
533
-
534
- const data = await response.json();
535
-
536
- if (data.success) {
537
- showToast('✅ Optimizations applied successfully! Backup created.', 'success');
538
- // Refresh dashboard after a short delay
539
- setTimeout(() => refreshDashboard(), 1000);
540
- } else {
541
- showToast(data.error || 'Failed to apply optimizations', 'error');
542
- }
543
- } catch (error) {
544
- console.error('Apply failed:', error);
545
- showToast('Failed to apply optimizations. Make sure the API server is running.', 'error');
679
+ function renderRecDetails(details) {
680
+ if (!details.length) return '';
681
+ return `
682
+ <div class="rec-details">
683
+ ${details.map(d => `
684
+ <div class="rec-detail-item">
685
+ <div class="rec-detail-value">${escHtml(d)}</div>
686
+ </div>`).join('')}
687
+ </div>`;
688
+ }
689
+
690
+ function getImpactClass(impact) {
691
+ if (!impact) return 'impact-low';
692
+ const s = impact.toLowerCase();
693
+ if (s.includes('$') && parseInt(s.replace(/[^0-9]/g, '')) >= 20) return 'impact-high';
694
+ if (s.includes('$') && parseInt(s.replace(/[^0-9]/g, '')) >= 10) return 'impact-medium';
695
+ if (s.includes('prevent') || s.includes('control')) return 'impact-medium';
696
+ return 'impact-low';
697
+ }
698
+
699
+ function toggleRecExpand(i) {
700
+ const card = document.querySelector(`.rec-card[data-index="${i}"]`);
701
+ if (!card) return;
702
+ card.classList.toggle('expanded');
703
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
704
+ editedRecommendations[i].expanded = card.classList.contains('expanded');
705
+ }
706
+
707
+ function toggleRecSelection(i) {
708
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
709
+ editedRecommendations[i].selected = !editedRecommendations[i].selected;
710
+ const card = document.querySelector(`.rec-card[data-index="${i}"]`);
711
+ if (card) card.classList.toggle('selected', editedRecommendations[i].selected);
712
+ }
713
+
714
+ function toggleRecEdit(i) {
715
+ const card = document.querySelector(`.rec-card[data-index="${i}"]`);
716
+ if (!card) return;
717
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
718
+ const isEditing = !editedRecommendations[i].editing;
719
+ editedRecommendations[i].editing = isEditing;
720
+ card.classList.toggle('editing', isEditing);
721
+ // Also expand if not already
722
+ if (isEditing && !card.classList.contains('expanded')) {
723
+ card.classList.add('expanded');
724
+ editedRecommendations[i].expanded = true;
725
+ }
726
+ }
727
+
728
+ function saveRecEdit(i) {
729
+ const textarea = document.getElementById(`recEdit${i}`);
730
+ if (!textarea) return;
731
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
732
+ editedRecommendations[i].description = textarea.value;
733
+ editedRecommendations[i].editing = false;
734
+ updateRecommendations();
735
+ showToast('Recommendation updated');
736
+ }
737
+
738
+ function cancelRecEdit(i) {
739
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
740
+ editedRecommendations[i].editing = false;
741
+ // Reset textarea to last saved description
742
+ const textarea = document.getElementById(`recEdit${i}`);
743
+ const orig = editedRecommendations[i].description ?? analysisData.recommendations[i]?.description ?? '';
744
+ if (textarea) textarea.value = orig;
745
+ updateRecommendations();
746
+ }
747
+
748
+ function recDirty(i) {
749
+ // Mark as dirty — visual feedback could go here
750
+ }
751
+
752
+ function resetAllEdits() {
753
+ editedRecommendations = {};
754
+ updateRecommendations();
755
+ showToast('All edits reset');
756
+ }
757
+
758
+ async function applySelectedRecommendations() {
759
+ const selected = Object.entries(editedRecommendations)
760
+ .filter(([_, v]) => v.selected)
761
+ .map(([i]) => parseInt(i));
762
+
763
+ if (selected.length === 0) {
764
+ showToast('Select at least one recommendation to apply');
765
+ return;
766
+ }
767
+
768
+ const confirmed = confirm(`Apply ${selected.length} selected recommendation(s)? This will update your OpenClaw configuration.`);
769
+ if (!confirmed) return;
770
+
771
+ try {
772
+ const res = await fetch(`${API_BASE_URL}/api/apply`, {
773
+ method: 'POST',
774
+ headers: { 'Content-Type': 'application/json' },
775
+ body: JSON.stringify({ confirm: true })
776
+ });
777
+ const json = await res.json();
778
+ if (json.success) {
779
+ showToast(`✅ ${selected.length} optimization(s) applied!`);
780
+ // Uncheck applied items
781
+ selected.forEach(i => {
782
+ if (editedRecommendations[i]) editedRecommendations[i].selected = false;
783
+ });
784
+ updateRecommendations();
785
+ } else {
786
+ showToast(`Error: ${json.error || 'Apply failed'}`);
546
787
  }
788
+ } catch (err) {
789
+ showToast(`Network error: ${err.message}`);
790
+ }
547
791
  }
548
792
 
549
- // ============================================
550
- // OpenRouter Integration Functions
551
- // ============================================
793
+ /* ─── Model Details ─── */
794
+ function updateModelDetails() {
795
+ const container = document.getElementById('modelDetailsCard');
796
+ if (!container) return;
552
797
 
553
- /**
554
- * Check if OpenRouter API key is configured
555
- */
798
+ const models = analysisData.model_breakdown || [];
799
+ if (models.length === 0) {
800
+ container.innerHTML = '<div class="empty-state"><p>No model data available yet.</p></div>';
801
+ return;
802
+ }
803
+
804
+ const totalCost = models.reduce((s, m) => s + m.cost, 0);
805
+ container.innerHTML = `
806
+ <table class="model-table">
807
+ <thead>
808
+ <tr>
809
+ <th>Model</th>
810
+ <th>Tasks</th>
811
+ <th>Cost</th>
812
+ <th>Avg/Task</th>
813
+ <th>Share</th>
814
+ </tr>
815
+ </thead>
816
+ <tbody>
817
+ ${models.map(m => {
818
+ const share = totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) : '0.0';
819
+ return `<tr>
820
+ <td><span class="model-name">${escHtml(m.model)}</span></td>
821
+ <td>${m.tasks}</td>
822
+ <td>$${m.cost.toFixed(2)}</td>
823
+ <td>$${m.avg_cost_per_task.toFixed(3)}</td>
824
+ <td>${share}%</td>
825
+ </tr>`;
826
+ }).join('')}
827
+ </tbody>
828
+ </table>`;
829
+ }
830
+
831
+ /* ─── OpenRouter Integration ─── */
556
832
  async function checkOpenRouterConfig() {
557
- // Always show OpenRouter section
558
- document.getElementById('openRouterSection').style.display = 'block';
559
-
560
- try {
561
- const response = await fetch(`${API_BASE_URL}/config/openrouter-key`);
562
- if (response.ok) {
563
- const data = await response.json();
564
- openRouterConfigured = data.configured;
565
-
566
- if (openRouterConfigured) {
567
- await fetchOpenRouterUsage();
568
- } else {
569
- // Show configure prompt
570
- updateOpenRouterDisplay({ configured: false });
571
- }
572
- }
573
- } catch (error) {
574
- console.log('OpenRouter config check failed (API server may not be running):', error.message);
575
- // Still show section with configuration prompt
576
- updateOpenRouterDisplay({ configured: false });
833
+ try {
834
+ const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`);
835
+ if (!res.ok) return;
836
+ const json = await res.json();
837
+ if (json.configured) {
838
+ fetchOpenRouterUsage();
577
839
  }
840
+ } catch {
841
+ // API not available — continue in static mode
842
+ }
578
843
  }
579
844
 
580
- /**
581
- * Fetch actual OpenRouter usage from API
582
- */
583
845
  async function fetchOpenRouterUsage() {
846
+ const container = document.getElementById('openRouterContent');
847
+ try {
848
+ const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
849
+ if (!res.ok) return;
850
+ const json = await res.json();
851
+ if (json.success && json.configured) {
852
+ const usage = json.data || json;
853
+ container.innerHTML = `
854
+ <div class="or-stats-grid">
855
+ <div class="or-stat-card">
856
+ <div class="or-stat-label">Usage (USD)</div>
857
+ <div class="or-stat-value">$${(usage.usage || 0).toFixed(2)}</div>
858
+ </div>
859
+ <div class="or-stat-card">
860
+ <div class="or-stat-label">Limit</div>
861
+ <div class="or-stat-value">$${(usage.limit || 0).toFixed(2)}</div>
862
+ </div>
863
+ <div class="or-stat-card">
864
+ <div class="or-stat-label">Remaining</div>
865
+ <div class="or-stat-value">$${((usage.limit || 0) - (usage.usage || 0)).toFixed(2)}</div>
866
+ </div>
867
+ <div class="or-stat-card">
868
+ <div class="or-stat-label">Rate Limit</div>
869
+ <div class="or-stat-value">${usage.rate_limit?.requests || '--'}/s</div>
870
+ </div>
871
+ </div>`;
872
+ }
873
+ } catch {
874
+ // noop
875
+ }
876
+ }
877
+
878
+ /* ─── Config Modal ─── */
879
+ function openConfigModal() {
880
+ document.getElementById('configModal').style.display = 'flex';
881
+ const input = document.getElementById('apiKeyInput');
882
+ const stored = localStorage.getItem('smartmeter_openrouter_key');
883
+ if (stored && !input.value) input.value = stored;
884
+ input.focus();
885
+ const status = document.getElementById('configStatus');
886
+ status.className = 'config-status';
887
+ status.style.removeProperty('display');
888
+ }
889
+ function closeConfigModal() {
890
+ document.getElementById('configModal').style.display = 'none';
891
+ }
892
+
893
+ async function saveApiKey() {
894
+ const key = document.getElementById('apiKeyInput').value.trim();
895
+ const status = document.getElementById('configStatus');
896
+
897
+ function showStatus(msg, type) {
898
+ status.textContent = msg;
899
+ status.className = 'config-status ' + type;
900
+ status.style.removeProperty('display');
901
+ }
902
+
903
+ if (!key) {
904
+ showStatus('Please enter an API key.', 'error');
905
+ return;
906
+ }
907
+
908
+ if (!key.startsWith('sk-or-')) {
909
+ showStatus('Invalid format — key should start with sk-or-', 'error');
910
+ return;
911
+ }
912
+
913
+ showStatus('Validating…', 'validating');
914
+
915
+ let validated = false;
916
+ let errorMsg = '';
917
+
918
+ // Try API server first
919
+ try {
920
+ const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
921
+ method: 'POST',
922
+ headers: { 'Content-Type': 'application/json' },
923
+ body: JSON.stringify({ apiKey: key })
924
+ });
925
+ const json = await res.json();
926
+ if (json.success) {
927
+ validated = true;
928
+ } else {
929
+ errorMsg = json.error || 'Validation failed';
930
+ }
931
+ } catch (_) {
932
+ // API server not available — validate directly against OpenRouter
584
933
  try {
585
- const response = await fetch(`${API_BASE_URL}/openrouter-usage`);
586
- if (!response.ok) {
587
- throw new Error('Failed to fetch OpenRouter usage');
588
- }
589
-
590
- const data = await response.json();
591
- openRouterUsage = data;
592
-
593
- updateOpenRouterDisplay(data);
594
- } catch (error) {
595
- console.error('Failed to fetch OpenRouter usage:', error);
596
- updateOpenRouterDisplay({ success: false, error: error.message });
934
+ const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
935
+ headers: { 'Authorization': `Bearer ${key}` }
936
+ });
937
+ if (res.ok) {
938
+ const data = await res.json();
939
+ validated = !!(data && data.data);
940
+ if (!validated) errorMsg = 'Key not recognized by OpenRouter';
941
+ } else if (res.status === 401 || res.status === 403) {
942
+ errorMsg = 'Invalid API key — authentication failed';
943
+ } else {
944
+ errorMsg = `OpenRouter returned status ${res.status}`;
945
+ }
946
+ } catch (e2) {
947
+ errorMsg = 'Could not reach OpenRouter to validate — check your connection';
597
948
  }
949
+ }
950
+
951
+ if (validated) {
952
+ localStorage.setItem('smartmeter_openrouter_key', key);
953
+ showStatus('✅ API key saved and validated!', 'success');
954
+ setTimeout(() => {
955
+ closeConfigModal();
956
+ fetchOpenRouterUsage();
957
+ navigateTo('openrouter');
958
+ }, 1200);
959
+ } else {
960
+ showStatus(`❌ ${errorMsg}`, 'error');
961
+ }
598
962
  }
599
963
 
600
- /**
601
- * Update OpenRouter usage display in dashboard
602
- */
603
- function updateOpenRouterDisplay(data) {
604
- const content = document.getElementById('openRouterContent');
605
-
606
- if (!data.configured) {
607
- content.innerHTML = `
608
- <div class="openrouter-notice">
609
- <p>⚙️ Configure your OpenRouter API key to view actual usage and compare with analyzed data.</p>
610
- <button class="btn btn-primary" onclick="openConfigModal()">Configure API Key</button>
611
- </div>
612
- `;
613
- return;
964
+ /* ─── Preview Modal ─── */
965
+ async function viewConfig() {
966
+ try {
967
+ const res = await fetch(`${API_BASE_URL}/api/preview`);
968
+ const json = await res.json();
969
+ if (json.success) {
970
+ document.getElementById('previewConfigCode').textContent = JSON.stringify(json.config, null, 2);
971
+ document.getElementById('previewModal').style.display = 'flex';
972
+ } else {
973
+ showToast(`Error: ${json.error || 'No config available'}`);
614
974
  }
615
-
616
- if (!data.success) {
617
- content.innerHTML = `
618
- <div class="openrouter-error">
619
- <p>❌ Error fetching OpenRouter usage: ${data.error || 'Unknown error'}</p>
620
- <button class="btn btn-secondary" onclick="openConfigModal()">Update API Key</button>
621
- </div>
622
- `;
623
- return;
975
+ } catch (err) {
976
+ showToast(`Preview failed: ${err.message}`);
977
+ }
978
+ }
979
+ function closePreviewModal() {
980
+ document.getElementById('previewModal').style.display = 'none';
981
+ }
982
+
983
+ async function applyOptimizations() {
984
+ const confirmed = confirm('Apply all optimizations? A backup will be created.');
985
+ if (!confirmed) return;
986
+ try {
987
+ const res = await fetch(`${API_BASE_URL}/api/apply`, {
988
+ method: 'POST',
989
+ headers: { 'Content-Type': 'application/json' },
990
+ body: JSON.stringify({ confirm: true })
991
+ });
992
+ const json = await res.json();
993
+ if (json.success) {
994
+ showToast('✅ Optimizations applied! Backup created.');
995
+ closePreviewModal();
996
+ } else {
997
+ showToast(`Error: ${json.error || 'Apply failed'}`);
624
998
  }
625
-
626
- // Display actual usage
627
- const totalSpent = data.totalSpent !== null ? `$${data.totalSpent.toFixed(4)}` : 'N/A';
628
- const accountInfo = data.account || {};
629
-
630
- content.innerHTML = `
631
- <div class="openrouter-data">
632
- <div class="usage-grid">
633
- <div class="usage-item">
634
- <div class="usage-label">Account</div>
635
- <div class="usage-value">${accountInfo.label || 'Unknown'}</div>
636
- </div>
637
- <div class="usage-item">
638
- <div class="usage-label">Total Spent</div>
639
- <div class="usage-value highlight">${totalSpent}</div>
640
- </div>
641
- <div class="usage-item">
642
- <div class="usage-label">Usage Balance</div>
643
- <div class="usage-value">${accountInfo.usageBalance !== null ? `$${(accountInfo.usageBalance / 100).toFixed(2)}` : 'N/A'}</div>
644
- </div>
645
- <div class="usage-item">
646
- <div class="usage-label">Account Type</div>
647
- <div class="usage-value">${accountInfo.isFreeTier ? 'Free Tier' : 'Paid'}</div>
648
- </div>
649
- </div>
650
- ${getComparisonHtml(data)}
651
- <div class="openrouter-footer">
652
- <small>Last updated: ${new Date(data.timestamp).toLocaleString()}</small>
653
- <button class="btn-link" onclick="fetchOpenRouterUsage()">🔄 Refresh</button>
654
- </div>
655
- </div>
656
- `;
999
+ } catch (err) {
1000
+ showToast(`Apply failed: ${err.message}`);
1001
+ }
657
1002
  }
658
1003
 
659
- /**
660
- * Generate comparison HTML between analyzed and actual usage
661
- */
662
- function getComparisonHtml(openRouterData) {
663
- if (!currentData || !openRouterData.totalSpent) {
664
- return '';
1004
+ /* ─── Export ─── */
1005
+ async function exportReport() {
1006
+ try {
1007
+ const res = await fetch(`${API_BASE_URL}/api/export`);
1008
+ if (!res.ok) throw new Error('Export failed');
1009
+ const blob = await res.blob();
1010
+ const url = URL.createObjectURL(blob);
1011
+ const a = document.createElement('a');
1012
+ a.href = url;
1013
+ a.download = 'smartmeter-report.md';
1014
+ a.click();
1015
+ URL.revokeObjectURL(url);
1016
+ showToast('📄 Report downloaded');
1017
+ } catch (err) {
1018
+ // Fallback: generate from local data
1019
+ if (analysisData) {
1020
+ const md = generateLocalReport(analysisData);
1021
+ const blob = new Blob([md], { type: 'text/markdown' });
1022
+ const url = URL.createObjectURL(blob);
1023
+ const a = document.createElement('a');
1024
+ a.href = url;
1025
+ a.download = 'smartmeter-report.md';
1026
+ a.click();
1027
+ URL.revokeObjectURL(url);
1028
+ showToast('📄 Report exported from local data');
1029
+ } else {
1030
+ showToast('Export failed: no data');
665
1031
  }
666
-
667
- const analyzed = currentData.monthly_projected_current;
668
- const actual = openRouterData.totalSpent;
669
- const difference = Math.abs(analyzed - actual);
670
- const percentDiff = analyzed > 0 ? ((difference / analyzed) * 100).toFixed(1) : 0;
671
-
672
- return `
673
- <div class="comparison-section">
674
- <h4>📊 Comparison</h4>
675
- <div class="comparison-grid">
676
- <div class="comparison-item">
677
- <span class="comparison-label">SmartMeter Analyzed:</span>
678
- <span class="comparison-value">$${analyzed.toFixed(4)}</span>
679
- </div>
680
- <div class="comparison-item">
681
- <span class="comparison-label">OpenRouter Actual:</span>
682
- <span class="comparison-value">$${actual.toFixed(4)}</span>
683
- </div>
684
- <div class="comparison-item">
685
- <span class="comparison-label">Difference:</span>
686
- <span class="comparison-value ${analyzed > actual ? 'positive' : 'negative'}">
687
- ${analyzed > actual ? '-' : '+'}$${difference.toFixed(4)} (${percentDiff}%)
688
- </span>
689
- </div>
690
- </div>
691
- ${analyzed === 0 && actual > 0 ? `
692
- <div class="comparison-note">
693
- ℹ️ SmartMeter shows $0 because OpenRouter isn't including cost data in API responses.
694
- Your actual usage is ${totalSpent} as shown above.
695
- </div>
696
- ` : ''}
697
- </div>
698
- `;
1032
+ }
699
1033
  }
700
1034
 
701
- /**
702
- * Open API key configuration modal
703
- */
704
- function openConfigModal() {
705
- document.getElementById('configModal').style.display = 'flex';
706
- document.getElementById('configStatus').innerHTML = '';
1035
+ function generateLocalReport(d) {
1036
+ return `# SmartMeter Cost Analysis Report
1037
+
1038
+ **Generated:** ${new Date().toISOString()}
1039
+ **Period:** ${d.start_date} to ${d.end_date} (${d.days_analyzed} days)
1040
+
1041
+ ## Summary
1042
+ - Current Monthly Cost: $${(d.monthly_projected_current || 0).toFixed(2)}
1043
+ - Optimized Monthly Cost: $${(d.monthly_projected_optimized || 0).toFixed(2)}
1044
+ - Potential Savings: $${((d.monthly_projected_current || 0) - (d.monthly_projected_optimized || 0)).toFixed(2)}/month
1045
+ - Total Tasks: ${d.total_tasks}
1046
+ - Cache Hit Rate: ${((d.cache_hit_rate || 0) * 100).toFixed(1)}%
1047
+
1048
+ ## Recommendations
1049
+ ${(d.recommendations || []).map((r, i) => `${i + 1}. **${r.title}** — ${r.impact}\n ${r.description}`).join('\n\n')}
1050
+
1051
+ ---
1052
+ *Generated by SmartMeter*
1053
+ `;
707
1054
  }
708
1055
 
709
- /**
710
- * Close API key configuration modal
711
- */
712
- function closeConfigModal() {
713
- document.getElementById('configModal').style.display = 'none';
714
- document.getElementById('apiKeyInput').value = '';
715
- document.getElementById('configStatus').innerHTML = '';
1056
+ /* ─── Navigation ─── */
1057
+ function navigateTo(section) {
1058
+ // Update nav active state
1059
+ document.querySelectorAll('.nav-item').forEach(el => {
1060
+ el.classList.toggle('active', el.dataset.section === section);
1061
+ });
1062
+ // Toggle sections
1063
+ document.querySelectorAll('.page-section').forEach(el => {
1064
+ el.classList.toggle('active', el.id === `section-${section}`);
1065
+ });
1066
+ // Update page title
1067
+ const titles = { overview: 'Overview', recommendations: 'Recommendations', models: 'Models', openrouter: 'OpenRouter' };
1068
+ setText('pageTitle', titles[section] || section);
716
1069
  }
717
1070
 
718
- /**
719
- * Save and validate API key
720
- */
721
- async function saveApiKey() {
722
- const apiKey = document.getElementById('apiKeyInput').value.trim();
723
- const statusDiv = document.getElementById('configStatus');
724
-
725
- if (!apiKey) {
726
- statusDiv.innerHTML = '<div class="status-error">⚠️ Please enter an API key</div>';
727
- return;
728
- }
729
-
730
- if (!apiKey.startsWith('sk-or-')) {
731
- statusDiv.innerHTML = '<div class="status-error">⚠️ Invalid API key format (should start with "sk-or-")</div>';
732
- return;
733
- }
734
-
735
- statusDiv.innerHTML = '<div class="status-loading">⏳ Validating API key...</div>';
736
-
1071
+ function toggleSidebar() {
1072
+ document.getElementById('sidebar').classList.toggle('open');
1073
+ }
1074
+
1075
+ /* ─── Auto-Refresh ─── */
1076
+ function startAutoRefresh() {
1077
+ autoRefreshTimer = setInterval(async () => {
737
1078
  try {
738
- const response = await fetch(`${API_BASE_URL}/config/openrouter-key`, {
739
- method: 'POST',
740
- headers: {
741
- 'Content-Type': 'application/json'
742
- },
743
- body: JSON.stringify({ apiKey })
744
- });
745
-
746
- const data = await response.json();
747
-
748
- if (data.success) {
749
- statusDiv.innerHTML = '<div class="status-success">✅ API key saved and validated!</div>';
750
- setTimeout(() => {
751
- closeConfigModal();
752
- window.location.reload(); // Reload to show OpenRouter section
753
- }, 1500);
754
- } else {
755
- statusDiv.innerHTML = `<div class="status-error">❌ ${data.error || 'Validation failed'}</div>`;
1079
+ const res = await fetch('analysis.public.json', { cache: 'no-store' });
1080
+ if (res.ok) {
1081
+ const newData = await res.json();
1082
+ if (JSON.stringify(newData) !== JSON.stringify(analysisData)) {
1083
+ analysisData = newData;
1084
+ renderAll();
756
1085
  }
757
- } catch (error) {
758
- statusDiv.innerHTML = `<div class="status-error">❌ Failed to save: ${error.message}</div>`;
1086
+ }
1087
+ } catch {
1088
+ // silent
1089
+ }
1090
+ }, 5000);
1091
+ }
1092
+
1093
+ async function refreshDashboard() {
1094
+ await loadAnalysisData();
1095
+ showToast('Dashboard refreshed');
1096
+ }
1097
+
1098
+ /* ─── Cost Data Notice ─── */
1099
+ function checkCostDataNotice() {
1100
+ const d = analysisData;
1101
+ if (!d) return;
1102
+ const hasCostData = (d.monthly_projected_current || 0) > 0;
1103
+ const notice = document.getElementById('costDataNotice');
1104
+ if (notice) {
1105
+ // Only show when cost data is truly missing — keep hidden otherwise
1106
+ if (!hasCostData) {
1107
+ notice.style.display = 'flex';
759
1108
  }
1109
+ }
1110
+ }
1111
+
1112
+ /* ─── Helpers ─── */
1113
+ function setText(id, html) {
1114
+ const el = document.getElementById(id);
1115
+ if (el) el.innerHTML = html;
1116
+ }
1117
+
1118
+ function escHtml(str) {
1119
+ if (!str) return '';
1120
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
760
1121
  }
761
1122
 
762
- // Export functions for inline onclick handlers
763
- window.refreshDashboard = refreshDashboard;
764
- window.switchTab = switchTab;
765
- window.viewRecommendationDetails = viewRecommendationDetails;
766
- window.exportReport = exportReport;
767
- window.viewConfig = viewConfig;
768
- window.applyOptimizations = applyOptimizations;
769
- window.openConfigModal = openConfigModal;
770
- window.closeConfigModal = closeConfigModal;
771
- window.saveApiKey = saveApiKey;
772
- window.fetchOpenRouterUsage = fetchOpenRouterUsage;
773
-
774
- window.viewConfig = viewConfig;
775
- window.applyOptimizations = applyOptimizations;
1123
+ function updateLastUpdated() {
1124
+ const el = document.getElementById('lastUpdated');
1125
+ if (el) el.textContent = new Date().toLocaleTimeString();
1126
+ }
1127
+
1128
+ function hideLoading() {
1129
+ const ov = document.getElementById('loadingOverlay');
1130
+ if (ov) {
1131
+ ov.classList.add('hidden');
1132
+ setTimeout(() => ov.remove(), 400);
1133
+ }
1134
+ }
1135
+
1136
+ function showToast(msg, duration = 3000) {
1137
+ const el = document.getElementById('toast');
1138
+ if (!el) return;
1139
+ el.textContent = msg;
1140
+ el.classList.add('show');
1141
+ clearTimeout(el._timer);
1142
+ el._timer = setTimeout(() => el.classList.remove('show'), duration);
1143
+ }