diff --git a/media/webview.js b/media/webview.js index 764f79c..50c63f9 100644 --- a/media/webview.js +++ b/media/webview.js @@ -71,6 +71,33 @@ function showErrorBanner(msg) { return Math.round(num).toLocaleString(); } + function formatYAxisValue(value) { + if (!isFinite(value)) { + return '0'; + } + const abs = Math.abs(value); + if (abs >= 1000) { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + } + if (abs >= 1) { + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } + return value.toLocaleString(undefined, { maximumSignificantDigits: 3 }); + } + + function escapeHtml(input) { + if (input == null) return ''; + return String(input).replace(/[&<>"]/g, function (s) { + switch (s) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + default: return s; + } + }); + } + function renderSummary({ budget, spend, pct, warnAtPercent, dangerAtPercent, included, includedUsed, includedPct, view }) { const summary = document.getElementById('summary'); const warnRaw = Number(warnAtPercent ?? 75); @@ -190,7 +217,7 @@ function showErrorBanner(msg) {
Included Premium Requests: ${formatRequests(shownNumerator)} / ${formatRequests(included)} (${shownPct}%) - ${limitSourceText} + ${escapeHtml(limitSourceText)}
@@ -515,10 +542,10 @@ function showErrorBanner(msg) { el.className = 'metrics'; el.innerHTML = `
- Window: ${new Date(m.since).toLocaleDateString()} → ${new Date(m.until).toLocaleDateString()} - Days: ${m.days} - Engaged users (sum): ${m.engagedUsersSum} - Code suggestions (sum): ${m.codeSuggestionsSum} + Window: ${escapeHtml(new Date(m.since).toLocaleDateString())} → ${escapeHtml(new Date(m.until).toLocaleDateString())} + Days: ${escapeHtml(m.days)} + Engaged users (sum): ${escapeHtml(m.engagedUsersSum)} + Code suggestions (sum): ${escapeHtml(m.codeSuggestionsSum)}
`; const summary = document.querySelector('#summary'); @@ -535,10 +562,10 @@ function showErrorBanner(msg) { el.innerHTML = `
- ${includedLabel}: ${included} - ${localize ? (localize('cpum.webview.used', 'Used')) : 'Used'}: ${total} - ${localize ? (localize('cpum.webview.overage', 'Overage')) : 'Overage'}: ${overage}${overage > 0 ? ` ($${(overage * (b.pricePerPremiumRequest || 0.04)).toFixed(2)})` : ''} - ${priceLabel}: $${(b.pricePerPremiumRequest || 0.04).toFixed(2)} + ${escapeHtml(includedLabel)}: ${escapeHtml(included)} + ${escapeHtml(localize ? (localize('cpum.webview.used', 'Used')) : 'Used')}: ${escapeHtml(total)} + ${escapeHtml(localize ? (localize('cpum.webview.overage', 'Overage')) : 'Overage')}: ${escapeHtml(overage)}${overage > 0 ? ` ($${(overage * (b.pricePerPremiumRequest || 0.04)).toFixed(2)})` : ''} + ${escapeHtml(priceLabel)}: $${escapeHtml((b.pricePerPremiumRequest || 0.04).toFixed(2))}
`; const summary = document.querySelector('#summary'); @@ -765,6 +792,9 @@ function showErrorBanner(msg) { } // Usage History Rendering Functions + let currentTimeRange = 'all'; // Track selected time range + let allSnapshots = null; // Store all snapshots for filtering + function renderUsageHistory(historyData) { try { log('[renderUsageHistory] called with: ' + JSON.stringify(historyData)); } catch { } const section = document.getElementById('usage-history-section'); @@ -782,6 +812,32 @@ function showErrorBanner(msg) { const { trend, recentSnapshots } = historyData; + // Store all snapshots for time range filtering + if (recentSnapshots && recentSnapshots.length > 0) { + allSnapshots = recentSnapshots; + } + + // Set up time range selector if not already done + const timeRangeSelect = document.getElementById('time-range-select'); + if (timeRangeSelect && !timeRangeSelect.dataset.initialized) { + timeRangeSelect.dataset.initialized = 'true'; + timeRangeSelect.value = currentTimeRange; + timeRangeSelect.addEventListener('change', (e) => { + currentTimeRange = e.target.value; + // Re-render with filtered snapshots + if (allSnapshots && allSnapshots.length > 0) { + const filtered = filterSnapshotsByTimeRange(allSnapshots, currentTimeRange); + currentSnapshots = filtered; + renderTrendChart(filtered); + + // Recalculate trend stats for the selected time range + if (filtered.length > 1) { + updateTrendStats(filtered); + } + } + }); + } + // Update trend stats if (trend) { document.getElementById('current-rate').textContent = trend.hourlyRate.toFixed(1); @@ -806,10 +862,93 @@ function showErrorBanner(msg) { confidenceEl.textContent = trend.confidence + ' confidence'; } - // Render chart + // Render chart with filtered snapshots if (recentSnapshots && recentSnapshots.length > 1) { - currentSnapshots = recentSnapshots; // Store for resize handling - renderTrendChart(recentSnapshots); + const filtered = filterSnapshotsByTimeRange(recentSnapshots, currentTimeRange); + currentSnapshots = filtered; // Store for resize handling + renderTrendChart(filtered); + } + + // Render multi-month analysis if available + if (historyData.multiMonthAnalysis) { + renderMultiMonthAnalysis(historyData.multiMonthAnalysis); + } + } + + function filterSnapshotsByTimeRange(snapshots, range) { + if (!snapshots || snapshots.length === 0) { + return snapshots; + } + + if (range === 'all') { + return snapshots; + } + + const now = Date.now(); + let cutoffTime = 0; + + switch (range) { + case '24h': + cutoffTime = now - (24 * 60 * 60 * 1000); + break; + case '7d': + cutoffTime = now - (7 * 24 * 60 * 60 * 1000); + break; + case '30d': + cutoffTime = now - (30 * 24 * 60 * 60 * 1000); + break; + default: + return snapshots; + } + + return snapshots.filter(s => s.timestamp >= cutoffTime); + } + + function updateTrendStats(snapshots) { + if (!snapshots || snapshots.length < 2) { + return; + } + + // Calculate trend from filtered snapshots + const sortedSnapshots = [...snapshots].sort((a, b) => a.timestamp - b.timestamp); + const firstSnapshot = sortedSnapshots[0]; + const lastSnapshot = sortedSnapshots[sortedSnapshots.length - 1]; + + const timeRangeMs = lastSnapshot.timestamp - firstSnapshot.timestamp; + const timeRangeHours = timeRangeMs / (1000 * 60 * 60); + + if (timeRangeHours <= 0) { + return; + } + + const usageChange = lastSnapshot.totalQuantity - firstSnapshot.totalQuantity; + const hourlyRate = usageChange / timeRangeHours; + + // Update stats + document.getElementById('current-rate').textContent = hourlyRate.toFixed(1); + document.getElementById('daily-projection').textContent = Math.round(hourlyRate * 24); + document.getElementById('weekly-projection').textContent = Math.round(hourlyRate * 24 * 7); + + // Update trend direction + const directionEl = document.getElementById('trend-direction'); + const confidenceEl = document.getElementById('trend-confidence'); + + const changePercent = firstSnapshot.totalQuantity > 0 + ? (usageChange / firstSnapshot.totalQuantity) * 100 + : 0; + + if (Math.abs(changePercent) < 5) { + directionEl.textContent = '→ Stable'; + directionEl.style.color = 'var(--vscode-foreground)'; + confidenceEl.textContent = 'medium confidence'; + } else if (changePercent > 0) { + directionEl.textContent = '↗ Rising'; + directionEl.style.color = '#e51400'; + confidenceEl.textContent = (Math.abs(changePercent) > 20 ? 'high' : 'medium') + ' confidence'; + } else { + directionEl.textContent = '↘ Falling'; + directionEl.style.color = '#2d7d46'; + confidenceEl.textContent = (Math.abs(changePercent) > 20 ? 'high' : 'medium') + ' confidence'; } } @@ -945,8 +1084,8 @@ function showErrorBanner(msg) { // Y axis labels ctx.textAlign = 'right'; - ctx.fillText(minQuantity.toString(), margin.left - 10, displayHeight - margin.bottom); - ctx.fillText(maxQuantity.toString(), margin.left - 10, margin.top + 5); + ctx.fillText(formatYAxisValue(minQuantity), margin.left - 10, displayHeight - margin.bottom); + ctx.fillText(formatYAxisValue(maxQuantity), margin.left - 10, margin.top + 5); } // Store current snapshots for resize handling @@ -961,5 +1100,110 @@ function showErrorBanner(msg) { } }); + function renderMultiMonthAnalysis(analysis) { + try { log('[renderMultiMonthAnalysis] called with: ' + JSON.stringify(analysis)); } catch { } + + const section = document.getElementById('multi-month-analysis-section'); + if (!section) { + // Create the section dynamically if it doesn't exist + const historySection = document.getElementById('usage-history-section'); + if (!historySection) return; + + const newSection = document.createElement('div'); + newSection.id = 'multi-month-analysis-section'; + newSection.className = 'section'; + newSection.style.marginTop = '20px'; + historySection.parentElement.insertBefore(newSection, historySection.nextSibling); + } + + const container = document.getElementById('multi-month-analysis-section'); + if (!analysis || !analysis.dataMonths || analysis.dataMonths < 2) { + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + + // Build the HTML + let html = '

Multi-Month Analysis

'; + html += '
'; + html += '

Analysis Period: ' + analysis.dataMonths + ' month' + (analysis.dataMonths > 1 ? 's' : '') + ' of data

'; + + // Growth Trends + if (analysis.growthTrends && analysis.growthTrends.length > 0) { + html += ''; + } + + // Predictions + if (analysis.predictions && analysis.predictions.length > 0) { + html += '
'; + html += '

🔮 Next Month Predictions

'; + analysis.predictions.forEach(pred => { + html += '
'; + html += '
' + pred.month + ':
'; + html += '
Predicted usage: ' + Math.round(pred.predictedUsage) + ' ± ' + Math.round(pred.confidenceInterval) + '
'; + html += '
Confidence: ' + pred.confidence + '
'; + html += '
'; + }); + html += '
'; + } + + // Seasonality + if (analysis.seasonality && analysis.seasonality.detected) { + html += '
'; + html += '

📅 Seasonality Pattern

'; + html += '
'; + html += '
Pattern: ' + analysis.seasonality.pattern + '
'; + html += '
Peak Months: ' + analysis.seasonality.peakMonths.join(', ') + '
'; + html += '
Low Months: ' + analysis.seasonality.lowMonths.join(', ') + '
'; + html += '
Variation: ' + analysis.seasonality.variance.toFixed(1) + '%
'; + html += '
'; + html += '
'; + } + + // Anomalies + if (analysis.anomalies && analysis.anomalies.length > 0) { + html += '
'; + html += '

⚠️ Anomalies Detected

'; + analysis.anomalies.forEach(anomaly => { + const severityColor = anomaly.severity === 'high' ? '#e51400' : anomaly.severity === 'medium' ? '#f59d00' : '#f59d00'; + html += '
' + escapeHtml(anomaly.month) + ': ' + escapeHtml(anomaly.type) + '
'; + html += '
Expected: ' + Math.round(anomaly.expected) + ' | Actual: ' + Math.round(anomaly.actual) + ' | Deviation: ' + (anomaly.deviation > 0 ? '+' : '') + anomaly.deviation.toFixed(1) + '%
'; + html += '
Severity: ' + escapeHtml(anomaly.severity) + '
'; + html += '
'; + }); + html += '
'; + } + + // Insights + if (analysis.insights && analysis.insights.length > 0) { + html += '
'; + html += '

💡 Insights

'; + html += '
    '; + analysis.insights.forEach(insight => { + html += '
  • ' + escapeHtml(insight) + '
  • '; + }); + html += '
'; + html += '
'; + } + + html += '
'; + + container.innerHTML = html; + } + vscode?.postMessage({ type: 'getConfig' }); })(); diff --git a/src/extension.ts b/src/extension.ts index 9075af1..fc937a4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { computeUsageBar, pickIcon, formatRelativeTime } from './lib/format'; -import { computeIncludedOverageSummary, calculateIncludedQuantity, type BillingUsageItem } from './lib/usageUtils'; +import { computeIncludedOverageSummary, calculateIncludedQuantity, normalizeUsageQuantity, type BillingUsageItem } from './lib/usageUtils'; import { readStoredToken, migrateSettingToken, writeToken, clearToken, getSecretStorageKey } from './secrets'; import { deriveTokenState, recordMigrationKeep, recordSecureSetAndLegacyCleared, resetAllTokenStateWindows, debugSnapshot, recordSecureCleared } from './lib/tokenState'; import { setSecretsLogger, logSecrets } from './secrets_log'; @@ -390,25 +390,32 @@ class UsagePanel { const userPrice = Number(cfg.get('pricePerPremiumRequest') ?? 0.04) || 0.04; // Use the new helper function for consistent plan priority logic - const effectiveIncluded = getEffectiveIncludedRequests(cfg, billing.totalIncludedQuantity); + const effectiveIncludedRaw = getEffectiveIncludedRequests(cfg, billing.totalIncludedQuantity); + const normalizedTotalQuantity = normalizeUsageQuantity(billing.totalQuantity); + const normalizedBillingIncluded = normalizeUsageQuantity(billing.totalIncludedQuantity); + const normalizedEffectiveIncluded = normalizeUsageQuantity(effectiveIncludedRaw); + const normalizedOverageQuantity = normalizeUsageQuantity(Math.max(0, normalizedTotalQuantity - normalizedEffectiveIncluded)); + const normalizedNetAmount = normalizeUsageQuantity(billing.totalNetAmount, 2); const billingWithOverrides = { ...billing, + totalQuantity: normalizedTotalQuantity, + totalNetAmount: normalizedNetAmount, pricePerPremiumRequest: userPrice, userConfiguredIncluded: userIncluded > 0, userConfiguredPrice: userPrice !== 0.04, - totalIncludedQuantity: effectiveIncluded, - totalOverageQuantity: Math.max(0, billing.totalQuantity - effectiveIncluded) + totalIncludedQuantity: normalizedEffectiveIncluded, + totalOverageQuantity: normalizedOverageQuantity }; // Persist a compact billing snapshot using RAW billing included. We recompute the effective included // (custom > plan > billing) at render time to avoid baking overrides into the snapshot and causing // precedence drift across refreshes. try { await extCtx!.globalState.update('copilotPremiumUsageMonitor.lastBilling', { - totalQuantity: billing.totalQuantity, - totalIncludedQuantity: billing.totalIncludedQuantity, + totalQuantity: normalizedTotalQuantity, + totalIncludedQuantity: normalizedBillingIncluded, // Keep the (possibly user-configured) price so overage cost displays remain accurate - pricePerPremiumRequest: userPrice || 0.04 + pricePerPremiumRequest: normalizeUsageQuantity(userPrice || 0.04, 4) }); } catch { /* noop */ } this.post({ type: 'billing', billing: billingWithOverrides }); @@ -418,12 +425,18 @@ class UsagePanel { const cfg = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor'); const userPrice = Number(cfg.get('pricePerPremiumRequest') ?? 0.04) || 0.04; await extCtx!.globalState.update('copilotPremiumUsageMonitor.lastBilling', { - totalQuantity: billing.totalQuantity, - totalIncludedQuantity: billing.totalIncludedQuantity, - pricePerPremiumRequest: userPrice + totalQuantity: normalizeUsageQuantity(billing.totalQuantity), + totalIncludedQuantity: normalizeUsageQuantity(billing.totalIncludedQuantity), + pricePerPremiumRequest: normalizeUsageQuantity(userPrice, 4) }); } catch { /* noop */ } - this.post({ type: 'billing', billing }); + const sanitizedFallback = { + ...billing, + totalQuantity: normalizeUsageQuantity(billing.totalQuantity), + totalIncludedQuantity: normalizeUsageQuantity(billing.totalIncludedQuantity), + totalNetAmount: normalizeUsageQuantity(billing.totalNetAmount, 2) + }; + this.post({ type: 'billing', billing: sanitizedFallback }); } this.post({ type: 'clearError' }); void this.update(); @@ -503,7 +516,18 @@ class UsagePanel {

${localize('cpum.title', 'Copilot Premium Usage Monitor')}