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 += '
';
+ html += '
📈 Growth Trends
';
+ analysis.growthTrends.forEach(trend => {
+ const trendIcon = trend.direction === 'increasing' ? '↗' : trend.direction === 'decreasing' ? '↘' : '→';
+ const trendColor = trend.direction === 'increasing' ? '#e51400' : trend.direction === 'decreasing' ? '#2d7d46' : 'inherit';
+ html += '
';
+ html += '
' + trend.metric + ': ' + trendIcon + ' ' + trend.direction + '
';
+ html += '
Average: ' + trend.avgValue.toFixed(1) + ' | Change: ' + (trend.changePercent > 0 ? '+' : '') + trend.changePercent.toFixed(1) + '%
';
+ if (trend.significance !== 'none') {
+ html += '
Significance: ' + trend.significance + '
';
+ }
+ html += '
';
+ });
+ 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')}
-
Usage History & Trends
+
+
Usage History & Trends
+
+
+
+
+
Request Rate Trend
@@ -557,12 +581,21 @@ class UsagePanel {
}
private async maybeShowFirstRunNotice() { const key = 'copilotPremiumUsageMonitor.firstRunShown'; const shown = this.globalState.get
(key); const cfg = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor'); const disabled = cfg.get('disableFirstRunTips') === true || this.globalState.get('copilotPremiumUsageMonitor.firstRunDisabled') === true; if (shown || disabled) return; this.post({ type: 'notice', severity: 'info', text: localize('cpum.firstRun.tip', "Tip: Org metrics use your GitHub sign-in (read:org). Personal spend needs a PAT with 'Plan: read-only'. Avoid syncing your PAT. Click Help to learn more."), helpAction: true, dismissText: localize('cpum.firstRun.dismiss', "Don't show again"), learnMoreText: localize('cpum.firstRun.learnMore', 'Learn more'), openBudgetsText: localize('cpum.firstRun.openBudgets', 'Open budgets'), budgetsUrl: 'https://github.com/settings/billing/budgets' }); await this.globalState.update(key, true); }
private async setSpend(v: number) {
- await this.globalState.update('copilotPremiumUsageMonitor.currentSpend', v);
+ const normalizedSpend = normalizeUsageQuantity(v, 2);
+ await this.globalState.update('copilotPremiumUsageMonitor.currentSpend', normalizedSpend);
updateStatusBar();
// Collect usage history snapshot if appropriate
void this.maybeCollectUsageSnapshot();
}
- private getSpend(): number { const stored = this.globalState.get('copilotPremiumUsageMonitor.currentSpend'); if (typeof stored === 'number') return stored; const cfg = vscode.workspace.getConfiguration(); const legacy = cfg.get('copilotPremiumMonitor.currentSpend', 0); return legacy ?? 0; }
+ private getSpend(): number {
+ const stored = this.globalState.get('copilotPremiumUsageMonitor.currentSpend');
+ if (typeof stored === 'number') {
+ return normalizeUsageQuantity(stored, 2);
+ }
+ const cfg = vscode.workspace.getConfiguration();
+ const legacy = cfg.get('copilotPremiumMonitor.currentSpend', 0);
+ return normalizeUsageQuantity(legacy ?? 0, 2);
+ }
private async update() {
// Check if this is the first initialization
const isFirstInit = !this.htmlInitialized;
@@ -641,8 +674,8 @@ class UsagePanel {
// Calculate included usage using plan data priority
const config = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor');
const includedFromBilling = Number(lastBilling.totalIncludedQuantity || 0);
- const included = getEffectiveIncludedRequests(config, includedFromBilling);
- const totalQuantity = Number(lastBilling.totalQuantity || 0);
+ const included = normalizeUsageQuantity(getEffectiveIncludedRequests(config, includedFromBilling));
+ const totalQuantity = normalizeUsageQuantity(lastBilling.totalQuantity);
const includedUsed = totalQuantity;
try { getLog().appendLine(`[Usage History] Collecting snapshot: ${JSON.stringify({ totalQuantity, includedUsed, spend, included, selectedPlanId: config.get('selectedPlanId'), userIncluded: config.get('includedPremiumRequests'), billingIncluded: includedFromBilling })}`); } catch { /* noop */ }
@@ -651,7 +684,7 @@ class UsagePanel {
await usageHistoryManager.collectSnapshot({
totalQuantity,
includedUsed,
- spend,
+ spend: normalizeUsageQuantity(spend, 2),
included
});
} catch (error) {
@@ -673,8 +706,8 @@ class UsagePanel {
// Calculate included usage using plan data priority
const config = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor');
const includedFromBilling = Number(lastBilling.totalIncludedQuantity || 0);
- const included = getEffectiveIncludedRequests(config, includedFromBilling);
- const totalQuantity = Number(lastBilling.totalQuantity || 0);
+ const included = normalizeUsageQuantity(getEffectiveIncludedRequests(config, includedFromBilling));
+ const totalQuantity = normalizeUsageQuantity(lastBilling.totalQuantity);
const includedUsed = totalQuantity;
try { getLog().appendLine(`[Usage History Force] Collecting snapshot: ${JSON.stringify({ totalQuantity, includedUsed, spend, included, selectedPlanId: config.get('selectedPlanId'), userIncluded: config.get('includedPremiumRequests'), billingIncluded: includedFromBilling })}`); } catch { /* noop */ }
@@ -683,7 +716,7 @@ class UsagePanel {
await usageHistoryManager.collectSnapshot({
totalQuantity,
includedUsed,
- spend,
+ spend: normalizeUsageQuantity(spend, 2),
included
});
} catch (error) {
@@ -1370,17 +1403,18 @@ export function calculateCurrentUsageData() {
const config = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor');
const lastBilling = extCtx.globalState.get('copilotPremiumUsageMonitor.lastBilling');
- const spend = Number(extCtx.globalState.get('copilotPremiumUsageMonitor.currentSpend') ?? 0);
- const budget = Number(config.get('budget') ?? 0);
+ const spend = normalizeUsageQuantity(extCtx.globalState.get('copilotPremiumUsageMonitor.currentSpend') ?? 0, 2);
+ const budget = normalizeUsageQuantity(config.get('budget') ?? 0, 2);
// Calculate included requests data using consistent logic
const includedFromBilling = lastBilling ? Number(lastBilling.totalIncludedQuantity || 0) : 0;
- const included = getEffectiveIncludedRequests(config, includedFromBilling);
- const totalQuantity = lastBilling ? Number(lastBilling.totalQuantity || 0) : 0;
+ const includedEffective = getEffectiveIncludedRequests(config, normalizeUsageQuantity(includedFromBilling));
+ const included = normalizeUsageQuantity(includedEffective);
+ const totalQuantity = normalizeUsageQuantity(lastBilling ? Number(lastBilling.totalQuantity || 0) : 0);
// Show the actual used count even when it exceeds the included allotment so UI can display e.g. 134/50.
// Percent stays clamped to 100 so the meter doesn't overflow.
const includedUsed = totalQuantity;
- const includedPct = included > 0 ? Math.min(100, Math.round((totalQuantity / included) * 100)) : 0;
+ const includedPct = included > 0 ? Math.min(100, Math.round((includedUsed / included) * 100)) : 0;
// Calculate budget data
const budgetPct = budget > 0 ? Math.min(100, Math.round((spend / budget) * 100)) : 0;
@@ -1425,12 +1459,20 @@ export async function calculateCompleteUsageData() {
const recentCount = Array.isArray(recentSnapshots) ? recentSnapshots.length : 0;
// Use recent 48h snapshot count for consistency with UI/tests
const dataSize = { snapshots: recentCount, estimatedKB: (dataSizeRaw as any)?.estimatedKB ?? 0 } as any;
- // Debug logging removed after stabilizing tests; keep calculation deterministic without noisy logs.
+
+ // Get multi-month analysis if sufficient data exists
+ let multiMonthAnalysis = null;
+ try {
+ multiMonthAnalysis = await Promise.resolve(usageHistoryManager.analyzeMultiMonthTrends());
+ } catch (error) {
+ console.error('Failed to get multi-month analysis:', error);
+ }
historyData = {
trend,
recentSnapshots,
- dataSize
+ dataSize,
+ multiMonthAnalysis
};
} catch (error) {
console.error('Failed to get usage history data:', error);
@@ -1454,8 +1496,8 @@ function updateStatusBar() {
}
const cfg = vscode.workspace.getConfiguration('copilotPremiumUsageMonitor');
const base = calculateCurrentUsageData();
- const budget = Number(cfg.get('budget') ?? 0);
- const spend = extCtx.globalState.get('copilotPremiumUsageMonitor.currentSpend') ?? 0;
+ const budget = normalizeUsageQuantity(cfg.get('budget') ?? 0, 2);
+ const spend = normalizeUsageQuantity(extCtx.globalState.get('copilotPremiumUsageMonitor.currentSpend') ?? 0, 2);
// Two-phase meter:
// Phase 1: Included usage grows until includedUsed >= included (if included > 0)
// Phase 2: Reset meter to show spend vs budget growth for overage period
diff --git a/src/lib/usageHistory.ts b/src/lib/usageHistory.ts
index 8db6a06..39bf615 100644
--- a/src/lib/usageHistory.ts
+++ b/src/lib/usageHistory.ts
@@ -79,7 +79,7 @@ export class UsageHistoryManager {
// Check if we need to archive old data (daily check)
if (now - history.lastArchiveCheck > ARCHIVE_CHECK_INTERVAL) {
- await this.archiveOldData(history, now);
+ this.archiveOldData(history, now);
history.lastArchiveCheck = now;
}
@@ -279,7 +279,7 @@ export class UsageHistoryManager {
* Archive old snapshots into daily and monthly aggregates
* Maintains hybrid storage: detailed recent, aggregated historical
*/
- private async archiveOldData(history: UsageHistory, now: number): Promise {
+ private archiveOldData(history: UsageHistory, now: number): void {
// 1. Aggregate snapshots older than 30 days into daily aggregates
const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000);
const oldSnapshots = history.snapshots.filter(s => s.timestamp <= thirtyDaysAgo);
@@ -455,9 +455,9 @@ export class UsageHistoryManager {
/**
* Compare current month to previous months
*/
- getMonthComparison(currentMonthRequests: number): {
- currentMonth: string;
- previousMonths: Array<{ month: string; requests: number; difference: number; percentChange: number }>
+ getMonthComparison(currentMonthRequests: number): {
+ currentMonth: string;
+ previousMonths: Array<{ month: string; requests: number; difference: number; percentChange: number }>
} | null {
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
@@ -474,8 +474,8 @@ export class UsageHistoryManager {
month: m.month,
requests: m.totalRequests,
difference: currentMonthRequests - m.totalRequests,
- percentChange: m.totalRequests > 0
- ? ((currentMonthRequests - m.totalRequests) / m.totalRequests) * 100
+ percentChange: m.totalRequests > 0
+ ? ((currentMonthRequests - m.totalRequests) / m.totalRequests) * 100
: 0
}));
@@ -491,34 +491,34 @@ export class UsageHistoryManager {
*/
analyzeMultiMonthTrends(): MultiMonthAnalysis | null {
const monthlyHistory = this.getMonthlyHistory();
-
+
if (monthlyHistory.length < 2) {
return null; // Need at least 2 months for trend analysis
}
// Sort chronologically (oldest first for analysis)
const sortedHistory = [...monthlyHistory].sort((a, b) => a.month.localeCompare(b.month));
-
+
// Calculate growth metrics
const growthTrend = this.calculateGrowthTrend(sortedHistory);
-
+
// Detect seasonality (requires 12+ months)
- const seasonality = sortedHistory.length >= 12
+ const seasonality = sortedHistory.length >= 12
? this.detectSeasonality(sortedHistory)
: null;
-
+
// Calculate moving averages
const movingAverages = this.calculateMovingAverages(sortedHistory);
-
+
// Predict next month's usage
const prediction = this.predictNextMonth(sortedHistory, growthTrend, seasonality);
-
+
// Identify anomalies
const anomalies = this.identifyAnomalies(sortedHistory, movingAverages);
-
+
// Generate insights
const insights = this.generateInsights(sortedHistory, growthTrend, seasonality, anomalies);
-
+
return {
growthTrend,
seasonality,
@@ -532,26 +532,25 @@ export class UsageHistoryManager {
private calculateGrowthTrend(sortedHistory: MonthlyAggregate[]): GrowthTrend {
const requestsData = sortedHistory.map(m => m.totalRequests);
- const spendData = sortedHistory.map(m => m.totalSpend);
-
+
// Calculate month-over-month growth rates
const requestsGrowthRates: number[] = [];
const spendGrowthRates: number[] = [];
-
+
for (let i = 1; i < sortedHistory.length; i++) {
const prevRequests = sortedHistory[i - 1].totalRequests;
const currRequests = sortedHistory[i].totalRequests;
if (prevRequests > 0) {
requestsGrowthRates.push(((currRequests - prevRequests) / prevRequests) * 100);
}
-
+
const prevSpend = sortedHistory[i - 1].totalSpend;
const currSpend = sortedHistory[i].totalSpend;
if (prevSpend > 0) {
spendGrowthRates.push(((currSpend - prevSpend) / prevSpend) * 100);
}
}
-
+
// Average growth rate
const avgRequestsGrowth = requestsGrowthRates.length > 0
? requestsGrowthRates.reduce((sum, r) => sum + r, 0) / requestsGrowthRates.length
@@ -559,13 +558,13 @@ export class UsageHistoryManager {
const avgSpendGrowth = spendGrowthRates.length > 0
? spendGrowthRates.reduce((sum, r) => sum + r, 0) / spendGrowthRates.length
: 0;
-
+
// Trend direction using linear regression
const trendDirection = this.calculateLinearTrend(requestsData);
-
+
// Volatility (standard deviation of growth rates)
const volatility = this.calculateStandardDeviation(requestsGrowthRates);
-
+
// Determine trend type
let trendType: 'accelerating' | 'decelerating' | 'steady' | 'volatile';
if (volatility > 30) {
@@ -583,7 +582,7 @@ export class UsageHistoryManager {
} else {
trendType = 'steady';
}
-
+
return {
direction: trendDirection.slope > 0 ? 'increasing' : trendDirection.slope < 0 ? 'decreasing' : 'stable',
avgMonthlyGrowthRequests: avgRequestsGrowth,
@@ -596,10 +595,10 @@ export class UsageHistoryManager {
private detectSeasonality(sortedHistory: MonthlyAggregate[]): SeasonalityPattern | null {
if (sortedHistory.length < 12) return null;
-
+
// Group by month of year (Jan, Feb, etc.)
const monthlyPatterns = new Map();
-
+
for (const record of sortedHistory) {
const month = parseInt(record.month.split('-')[1], 10); // Extract month number
if (!monthlyPatterns.has(month)) {
@@ -607,33 +606,33 @@ export class UsageHistoryManager {
}
monthlyPatterns.get(month)!.push(record.totalRequests);
}
-
+
// Calculate average for each month
const monthlyAverages = new Map();
for (const [month, values] of monthlyPatterns.entries()) {
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
monthlyAverages.set(month, avg);
}
-
+
// Calculate overall average
const overallAvg = Array.from(monthlyAverages.values()).reduce((sum, v) => sum + v, 0) / monthlyAverages.size;
-
+
// Detect peaks and troughs
const deviations = Array.from(monthlyAverages.entries()).map(([month, avg]) => ({
month,
deviation: ((avg - overallAvg) / overallAvg) * 100
}));
-
+
// Sort by deviation to find peaks and troughs
const sortedByDeviation = [...deviations].sort((a, b) => b.deviation - a.deviation);
const peakMonths = sortedByDeviation.slice(0, 3).filter(d => d.deviation > 10).map(d => d.month);
const troughMonths = sortedByDeviation.slice(-3).filter(d => d.deviation < -10).map(d => d.month);
-
+
// Seasonal strength (variance explained by seasonality)
const seasonalVariance = this.calculateVariance(deviations.map(d => d.deviation));
- const strength: 'strong' | 'moderate' | 'weak' =
+ const strength: 'strong' | 'moderate' | 'weak' =
seasonalVariance > 400 ? 'strong' : seasonalVariance > 100 ? 'moderate' : 'weak';
-
+
return {
detected: peakMonths.length > 0 || troughMonths.length > 0,
strength,
@@ -657,10 +656,10 @@ export class UsageHistoryManager {
}
return result;
};
-
+
const requests = sortedHistory.map(m => m.totalRequests);
const spend = sortedHistory.map(m => m.totalSpend);
-
+
return {
ma3Requests: calculate(requests, 3),
ma6Requests: calculate(requests, 6),
@@ -670,34 +669,34 @@ export class UsageHistoryManager {
}
private predictNextMonth(
- sortedHistory: MonthlyAggregate[],
+ sortedHistory: MonthlyAggregate[],
growthTrend: GrowthTrend,
seasonality: SeasonalityPattern | null
): MonthlyPrediction {
const latestMonth = sortedHistory[sortedHistory.length - 1];
-
+
// Base prediction on linear trend
const trend = this.calculateLinearTrend(sortedHistory.map(m => m.totalRequests));
let predictedRequests = latestMonth.totalRequests + trend.slope;
let predictedSpend = latestMonth.totalSpend * (1 + (growthTrend.avgMonthlyGrowthSpend / 100));
-
+
// Apply seasonal adjustment if detected
if (seasonality && seasonality.detected && seasonality.strength !== 'weak') {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
const nextMonthNum = nextMonth.getMonth() + 1; // 1-based
-
+
const seasonalFactor = seasonality.monthlyFactors.find(f => f.month === nextMonthNum);
if (seasonalFactor) {
predictedRequests *= seasonalFactor.factor;
predictedSpend *= seasonalFactor.factor;
}
}
-
+
// Calculate confidence intervals (±1 standard deviation)
const historicalRequests = sortedHistory.map(m => m.totalRequests);
const stdDev = this.calculateStandardDeviation(historicalRequests);
-
+
return {
month: this.getNextMonthString(),
predictedRequests: Math.round(predictedRequests),
@@ -712,20 +711,20 @@ export class UsageHistoryManager {
private identifyAnomalies(sortedHistory: MonthlyAggregate[], movingAverages: MovingAverages): MonthlyAnomaly[] {
const anomalies: MonthlyAnomaly[] = [];
-
+
if (movingAverages.ma6Requests.length === 0) {
return anomalies;
}
-
+
// Compare recent months against 6-month moving average
const startIdx = Math.max(0, sortedHistory.length - movingAverages.ma6Requests.length);
-
+
for (let i = 0; i < movingAverages.ma6Requests.length; i++) {
const monthIdx = startIdx + i;
const month = sortedHistory[monthIdx];
const ma6 = movingAverages.ma6Requests[i];
const deviation = ((month.totalRequests - ma6) / ma6) * 100;
-
+
// Flag as anomaly if deviation > 30%
if (Math.abs(deviation) > 30) {
anomalies.push({
@@ -734,11 +733,11 @@ export class UsageHistoryManager {
deviation: Math.round(deviation),
actualRequests: month.totalRequests,
expectedRequests: Math.round(ma6),
- possibleCause: this.inferAnomalyCause(deviation, month)
+ possibleCause: this.inferAnomalyCause(deviation)
});
}
}
-
+
return anomalies;
}
@@ -749,7 +748,7 @@ export class UsageHistoryManager {
anomalies: MonthlyAnomaly[]
): string[] {
const insights: string[] = [];
-
+
// Growth insights
if (growthTrend.avgMonthlyGrowthRequests > 10) {
insights.push(`⚠️ Usage is growing rapidly at ${growthTrend.avgMonthlyGrowthRequests.toFixed(1)}% per month. Consider increasing your budget.`);
@@ -758,7 +757,7 @@ export class UsageHistoryManager {
} else if (growthTrend.trendType === 'steady') {
insights.push(`✅ Usage is stable with minimal fluctuation (${growthTrend.avgMonthlyGrowthRequests.toFixed(1)}% avg growth).`);
}
-
+
// Volatility insights
if (growthTrend.trendType === 'volatile') {
insights.push(`📊 Usage patterns are volatile. Consider reviewing what's driving irregular usage.`);
@@ -767,13 +766,13 @@ export class UsageHistoryManager {
} else if (growthTrend.trendType === 'decelerating') {
insights.push(`📉 Usage growth is slowing down, approaching stability.`);
}
-
+
// Seasonality insights
if (seasonality && seasonality.detected) {
if (seasonality.strength === 'strong') {
const peakMonthNames = seasonality.peakMonths.map(m => this.getMonthName(m));
const troughMonthNames = seasonality.troughMonths.map(m => this.getMonthName(m));
-
+
if (peakMonthNames.length > 0) {
insights.push(`📅 Strong seasonal pattern: Peak usage in ${peakMonthNames.join(', ')}.`);
}
@@ -782,7 +781,7 @@ export class UsageHistoryManager {
}
}
}
-
+
// Anomaly insights
if (anomalies.length > 0) {
const recentAnomaly = anomalies[anomalies.length - 1];
@@ -792,7 +791,7 @@ export class UsageHistoryManager {
insights.push(`🔔 Unusual drop in ${recentAnomaly.month}: ${Math.abs(recentAnomaly.deviation)}% below normal. ${recentAnomaly.possibleCause}`);
}
}
-
+
// Historical context
if (sortedHistory.length >= 12) {
const firstMonth = sortedHistory[0];
@@ -800,17 +799,17 @@ export class UsageHistoryManager {
const totalGrowth = ((lastMonth.totalRequests - firstMonth.totalRequests) / firstMonth.totalRequests) * 100;
insights.push(`📊 Over ${sortedHistory.length} months, usage has ${totalGrowth > 0 ? 'increased' : 'decreased'} by ${Math.abs(totalGrowth).toFixed(1)}%.`);
}
-
+
return insights;
}
private assessDataQuality(sortedHistory: MonthlyAggregate[]): DataQuality {
const monthCount = sortedHistory.length;
const hasConsistentActivity = sortedHistory.every(m => m.daysActive >= 20);
-
+
let qualityScore: 'excellent' | 'good' | 'fair' | 'poor';
let completeness: number;
-
+
if (monthCount >= 12 && hasConsistentActivity) {
qualityScore = 'excellent';
completeness = 100;
@@ -824,16 +823,16 @@ export class UsageHistoryManager {
qualityScore = 'poor';
completeness = (monthCount / 12) * 100;
}
-
+
return {
score: qualityScore,
monthCount,
completeness: Math.round(completeness),
- recommendation: monthCount < 6
+ recommendation: monthCount < 6
? 'Collect at least 6 months of data for reliable trend analysis.'
: monthCount < 12
- ? 'Collect 12+ months for seasonal pattern detection.'
- : 'Sufficient data for comprehensive analysis.'
+ ? 'Collect 12+ months for seasonal pattern detection.'
+ : 'Sufficient data for comprehensive analysis.'
};
}
@@ -845,10 +844,10 @@ export class UsageHistoryManager {
const sumY = data.reduce((sum, v) => sum + v, 0);
const sumXY = x.reduce((sum, v, i) => sum + v * data[i], 0);
const sumX2 = x.reduce((sum, v) => sum + v * v, 0);
-
+
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
-
+
return { slope, intercept };
}
@@ -864,7 +863,7 @@ export class UsageHistoryManager {
return 'low';
}
- private inferAnomalyCause(deviation: number, month: MonthlyAggregate): string {
+ private inferAnomalyCause(deviation: number): string {
if (Math.abs(deviation) > 50) {
return 'Possible major event or data collection issue.';
}
diff --git a/src/lib/usageUtils.ts b/src/lib/usageUtils.ts
index 42d6ac1..1537097 100644
--- a/src/lib/usageUtils.ts
+++ b/src/lib/usageUtils.ts
@@ -1,6 +1,22 @@
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
+const DEFAULT_USAGE_FRACTION_DIGITS = 3;
+
+/**
+ * Normalize a numeric usage quantity by constraining it to a sane number of fractional digits.
+ * Helps prevent floating point artifacts (e.g. 406.59000000000003) from leaking into the UI.
+ */
+export function normalizeUsageQuantity(value: unknown, fractionDigits = DEFAULT_USAGE_FRACTION_DIGITS): number {
+ const num = Number(value);
+ if (!isFinite(num)) {
+ return 0;
+ }
+ const digits = Math.max(0, Math.min(10, Math.floor(fractionDigits)));
+ const factor = Math.pow(10, digits);
+ return Math.round(num * factor) / factor;
+}
+
export type BillingUsageItem = {
date: string;
product: string; // e.g., 'Copilot', 'Actions'
@@ -32,11 +48,13 @@ export function calculateIncludedQuantity(copilotItems: BillingUsageItem[]): num
export function computeIncludedOverageSummary(lastBilling: any, includedOverride?: number) {
try {
if (!lastBilling) return '';
- const total = Number(lastBilling.totalQuantity || 0);
+ const total = normalizeUsageQuantity(lastBilling.totalQuantity);
// Prefer an explicit included override (from selected plan or user-configured setting)
// otherwise fall back to the billing-provided included quantity.
- const included = typeof includedOverride === 'number' ? Number(includedOverride) : Number(lastBilling.totalIncludedQuantity || 0) || 0;
- const overage = Math.max(0, total - included);
+ const included = normalizeUsageQuantity(
+ typeof includedOverride === 'number' ? includedOverride : lastBilling.totalIncludedQuantity
+ );
+ const overage = normalizeUsageQuantity(Math.max(0, total - included));
const price = Number(lastBilling.pricePerPremiumRequest || 0.04) || 0.04;
// Use GitHub nomenclature.
const includedLabel = localize('cpum.statusbar.included', 'Included Premium Requests');
diff --git a/src/lib/viewModel.ts b/src/lib/viewModel.ts
index bdd08a0..6b980ba 100644
--- a/src/lib/viewModel.ts
+++ b/src/lib/viewModel.ts
@@ -1,3 +1,4 @@
+import { normalizeUsageQuantity } from './usageUtils';
export type UsageCompleteData = {
budget: number;
spend: number;
@@ -39,11 +40,11 @@ export type UsageViewModel = {
};
export function buildUsageViewModel(complete: UsageCompleteData, lastBilling?: LastBillingSnapshot): UsageViewModel {
- const included = Number(complete.included || 0);
- const used = Number(complete.includedUsed || 0);
- const shown = included > 0 ? Math.min(used, included) : 0;
+ const included = normalizeUsageQuantity(complete.included);
+ const used = normalizeUsageQuantity(complete.includedUsed);
+ const shown = normalizeUsageQuantity(included > 0 ? Math.min(used, included) : 0);
const pct = included > 0 ? Math.min(100, Math.max(0, Math.round((used / included) * 100))) : 0;
- const overageQty = Math.max(0, used - included);
+ const overageQty = normalizeUsageQuantity(Math.max(0, used - included));
const price = lastBilling && typeof lastBilling.pricePerPremiumRequest === 'number' ? lastBilling.pricePerPremiumRequest : undefined;
const overageCost = price !== undefined ? Number((overageQty * price).toFixed(2)) : undefined;
const warn = Number(complete.warnAt || 0);
@@ -52,8 +53,8 @@ export function buildUsageViewModel(complete: UsageCompleteData, lastBilling?: L
const includedColor = thresholdColor(pct, warn, danger);
return {
- budget: Number(complete.budget || 0),
- spend: Number(complete.spend || 0),
+ budget: normalizeUsageQuantity(complete.budget, 2),
+ spend: normalizeUsageQuantity(complete.spend, 2),
budgetPct: Number(complete.budgetPct || 0),
progressColor: complete.progressColor,
warnAt: Number(complete.warnAt || 0),