diff --git a/CHANGELOG.md b/CHANGELOG.md index 3213aab7..dffc23ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,7 +259,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how * add information where logs from the devContainer are stored by @UncleBats in https://github.com/rajbos/github-copilot-token-usage/pull/160 * Persist active tab state in diagnostic view by @Copilot in https://github.com/rajbos/github-copilot-token-usage/pull/164 * Add Azure Storage backend configuration panel to diagnostics by @Copilot in https://github.com/rajbos/github-copilot-token-usage/pull/163 -* Show last month stats next to this month by @rajbos in https://github.com/rajbos/github-copilot-token-usage/pull/166 +* Show Previous Month stats next to this month by @rajbos in https://github.com/rajbos/github-copilot-token-usage/pull/166 * Add clickable links for empty sessions in Diagnostic Report by @Copilot in https://github.com/rajbos/github-copilot-token-usage/pull/165 * Enhance usage analysis with model tracking features by @FokkoVeegens in https://github.com/rajbos/github-copilot-token-usage/pull/157 * Progressive loading for diagnostics view - eliminate 10-30s UI blocking by @Copilot in https://github.com/rajbos/github-copilot-token-usage/pull/169 diff --git a/docs/images/EnvironmentalImpact.png b/docs/images/EnvironmentalImpact.png new file mode 100644 index 00000000..d9553631 Binary files /dev/null and b/docs/images/EnvironmentalImpact.png differ diff --git a/esbuild.js b/esbuild.js index d1632414..35ff1df3 100644 --- a/esbuild.js +++ b/esbuild.js @@ -51,8 +51,7 @@ async function main() { logviewer: 'src/webview/logviewer/main.ts', maturity: 'src/webview/maturity/main.ts', dashboard: 'src/webview/dashboard/main.ts', - 'fluency-level-viewer': 'src/webview/fluency-level-viewer/main.ts', - }, + 'fluency-level-viewer': 'src/webview/fluency-level-viewer/main.ts', environmental: 'src/webview/environmental/main.ts', }, bundle: true, format: 'iife', minify: production, diff --git a/package.json b/package.json index 3b0f1504..1eb97ac1 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,11 @@ "command": "copilot-token-tracker.showDashboard", "title": "Show Team Dashboard", "category": "Copilot Token Tracker" + }, + { + "command": "copilot-token-tracker.showEnvironmental", + "title": "Show Environmental Impact", + "category": "Copilot Token Tracker" } ], "configuration": { diff --git a/src/extension.ts b/src/extension.ts index 44ecf0b2..f18cc441 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -166,6 +166,7 @@ class CopilotTokenTracker implements vscode.Disposable { private maturityPanel: vscode.WebviewPanel | undefined; private dashboardPanel: vscode.WebviewPanel | undefined; private fluencyLevelViewerPanel: vscode.WebviewPanel | undefined; + private environmentalPanel: vscode.WebviewPanel | undefined; private outputChannel: vscode.OutputChannel; private lastDetailedStats: DetailedStats | undefined; private lastDailyStats: DailyTokenStats[] | undefined; @@ -672,6 +673,25 @@ class CopilotTokenTracker implements vscode.Disposable { this.maturityPanel.webview.html = this.getMaturityHtml(this.maturityPanel.webview, maturityData); } + // If the environmental panel is open, update its content + if (this.environmentalPanel) { + if (silent) { + void this.environmentalPanel.webview.postMessage({ + command: 'updateStats', + data: { + today: detailedStats.today, + month: detailedStats.month, + lastMonth: detailedStats.lastMonth, + last30Days: detailedStats.last30Days, + lastUpdated: detailedStats.lastUpdated.toISOString(), + backendConfigured: this.isBackendConfigured(), + }, + }); + } else { + this.environmentalPanel.webview.html = this.getEnvironmentalHtml(this.environmentalPanel.webview, detailedStats); + } + } + this.log(`Updated stats - Today: ${detailedStats.today.tokens}, Last 30 Days: ${detailedStats.last30Days.tokens}`); // Store the stats for reuse without recalculation this.lastDetailedStats = detailedStats; @@ -738,7 +758,7 @@ class CopilotTokenTracker implements vscode.Disposable { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - // Calculate last month boundaries + // Calculate Previous Month boundaries const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); // Last day of previous month const lastMonthStart = new Date(lastMonthEnd.getFullYear(), lastMonthEnd.getMonth(), 1); // Calculate last 30 days boundary @@ -930,7 +950,7 @@ class CopilotTokenTracker implements vscode.Disposable { } } else if (lastActivity >= lastMonthStart && lastActivity <= lastMonthEnd) { - // Session is from last month - only track lastMonth stats + // Session is from Previous Month - only track lastMonth stats lastMonthStats.tokens += tokens; lastMonthStats.estimatedTokens += estimatedTokens; lastMonthStats.actualTokens += actualTokens; @@ -938,14 +958,14 @@ class CopilotTokenTracker implements vscode.Disposable { lastMonthStats.sessions += 1; lastMonthStats.interactions += interactions; - // Add editor usage to last month stats + // Add editor usage to Previous Month stats if (!lastMonthStats.editorUsage[editorType]) { lastMonthStats.editorUsage[editorType] = { tokens: 0, sessions: 0 }; } lastMonthStats.editorUsage[editorType].tokens += tokens; lastMonthStats.editorUsage[editorType].sessions += 1; - // Add model usage to last month stats + // Add model usage to Previous Month stats for (const [model, usage] of Object.entries(modelUsage)) { if (!lastMonthStats.modelUsage[model]) { lastMonthStats.modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; @@ -963,7 +983,7 @@ class CopilotTokenTracker implements vscode.Disposable { } } - this.log(`โœ… Analysis complete: Today ${todayStats.sessions} sessions, Month ${monthStats.sessions} sessions, Last 30 Days ${last30DaysStats.sessions} sessions, Last Month ${lastMonthStats.sessions} sessions`); + this.log(`โœ… Analysis complete: Today ${todayStats.sessions} sessions, Month ${monthStats.sessions} sessions, Last 30 Days ${last30DaysStats.sessions} sessions, Previous Month ${lastMonthStats.sessions} sessions`); if (skippedFiles > 0) { this.log(`โญ๏ธ Skipped ${skippedFiles} session file(s) (empty or no activity in recent months)`); } @@ -3051,6 +3071,9 @@ class CopilotTokenTracker implements vscode.Disposable { case 'showDashboard': await this.showDashboard(); break; + case 'showEnvironmental': + await this.showEnvironmental(); + break; case 'saveSortSettings': await this.context.globalState.update('details.sortSettings', message.settings); break; @@ -3064,6 +3087,108 @@ class CopilotTokenTracker implements vscode.Disposable { }); } + public async showEnvironmental(): Promise { + this.log('๐ŸŒฟ Opening Environmental Impact view'); + + if (this.environmentalPanel) { + this.environmentalPanel.reveal(); + this.log('๐ŸŒฟ Environmental Impact view revealed (already exists)'); + return; + } + + let stats = this.lastDetailedStats; + if (!stats) { + stats = await this.updateTokenStats(); + if (!stats) { + return; + } + } + + this.environmentalPanel = vscode.window.createWebviewPanel( + 'copilotEnvironmental', + 'Environmental Impact', + { viewColumn: vscode.ViewColumn.One, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: false, + localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview')] + } + ); + + this.environmentalPanel.webview.html = this.getEnvironmentalHtml(this.environmentalPanel.webview, stats); + + this.environmentalPanel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case 'refresh': { + const refreshed = await this.updateTokenStats(); + if (refreshed && this.environmentalPanel) { + this.environmentalPanel.webview.html = this.getEnvironmentalHtml(this.environmentalPanel.webview, refreshed); + } + break; + } + case 'showDetails': + await this.showDetails(); + break; + case 'showChart': + await this.showChart(); + break; + case 'showUsageAnalysis': + await this.showUsageAnalysis(); + break; + case 'showDiagnostics': + await this.showDiagnosticReport(); + break; + case 'showMaturity': + await this.showMaturity(); + break; + case 'showDashboard': + await this.showDashboard(); + break; + } + }); + + this.environmentalPanel.onDidDispose(() => { + this.log('๐ŸŒฟ Environmental Impact view closed'); + this.environmentalPanel = undefined; + }); + } + + private getEnvironmentalHtml(webview: vscode.Webview, stats: DetailedStats): string { + const nonce = this.getNonce(); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview', 'environmental.js') + ); + + const csp = [ + `default-src 'none'`, + `img-src ${webview.cspSource} https: data:`, + `style-src 'unsafe-inline' ${webview.cspSource}`, + `font-src ${webview.cspSource} https: data:`, + `script-src 'nonce-${nonce}'`, + ].join('; '); + + const dataWithBackend = { + ...stats, + backendConfigured: this.isBackendConfigured(), + }; + const initialData = JSON.stringify(dataWithBackend).replace(/ + + + + + + Environmental Impact + + +
+ + + + `; + } + public async showChart(): Promise { this.log('๐Ÿ“ˆ Opening Chart view'); @@ -3118,6 +3243,9 @@ class CopilotTokenTracker implements vscode.Disposable { case 'showDashboard': await this.showDashboard(); break; + case 'showEnvironmental': + await this.showEnvironmental(); + break; } }); @@ -3182,6 +3310,9 @@ class CopilotTokenTracker implements vscode.Disposable { case 'showDashboard': await this.showDashboard(); break; + case 'showEnvironmental': + await this.showEnvironmental(); + break; case 'analyseRepository': await this.handleAnalyseRepository(message.workspacePath); break; @@ -3891,6 +4022,9 @@ Return ONLY the JSON object, no markdown formatting, no explanations.`; case 'showDashboard': await this.showDashboard(); break; + case 'showEnvironmental': + await this.showEnvironmental(); + break; case 'shareToLinkedIn': await this.shareToSocialMedia('linkedin'); break; @@ -4591,6 +4725,9 @@ ${hashtag}`; case "showMaturity": await this.showMaturity(); break; + case "showEnvironmental": + await this.showEnvironmental(); + break; case "deleteUserDataset": await this.handleDeleteUserDataset(message.userId, message.datasetId); break; @@ -5681,6 +5818,9 @@ ${hashtag}`; case "showDashboard": await this.showDashboard(); break; + case "showEnvironmental": + await this.showEnvironmental(); + break; case "resetDebugCounters": await this.context.globalState.update('extension.openCount', 0); await this.context.globalState.update('extension.unknownMcpOpenCount', 0); @@ -6529,6 +6669,14 @@ export function activate(context: vscode.ExtensionContext) { }, ); + const showEnvironmentalCommand = vscode.commands.registerCommand( + "copilot-token-tracker.showEnvironmental", + async () => { + tokenTracker.log("Show environmental impact command called"); + await tokenTracker.showEnvironmental(); + }, + ); + // Register the show fluency level viewer command (debug-only) const showFluencyLevelViewerCommand = vscode.commands.registerCommand( "copilot-token-tracker.showFluencyLevelViewer", @@ -6565,6 +6713,7 @@ export function activate(context: vscode.ExtensionContext) { showMaturityCommand, showFluencyLevelViewerCommand, showDashboardCommand, + showEnvironmentalCommand, generateDiagnosticReportCommand, clearCacheCommand, tokenTracker, diff --git a/src/webview/chart/main.ts b/src/webview/chart/main.ts index ff1aacaa..1456dd65 100644 --- a/src/webview/chart/main.ts +++ b/src/webview/chart/main.ts @@ -85,6 +85,7 @@ function renderLayout(data: InitialChartData): void { createButton(BUTTONS['btn-refresh']), createButton(BUTTONS['btn-details']), createButton(BUTTONS['btn-usage']), + createButton(BUTTONS['btn-environmental']), createButton(BUTTONS['btn-diagnostics']), createButton(BUTTONS['btn-maturity']) ); @@ -178,6 +179,9 @@ function wireInteractions(data: InitialChartData): void { const dashboard = document.getElementById('btn-dashboard'); dashboard?.addEventListener('click', () => vscode.postMessage({ command: 'showDashboard' })); + const environmental = document.getElementById('btn-environmental'); + environmental?.addEventListener('click', () => vscode.postMessage({ command: 'showEnvironmental' })); + const viewButtons = [ { id: 'view-total', view: 'total' as const }, { id: 'view-model', view: 'model' as const }, diff --git a/src/webview/dashboard/main.ts b/src/webview/dashboard/main.ts index a384851c..462b0a17 100644 --- a/src/webview/dashboard/main.ts +++ b/src/webview/dashboard/main.ts @@ -160,6 +160,7 @@ function renderShell(root: HTMLElement, stats: DashboardStats): void { createButton(BUTTONS["btn-details"]), createButton(BUTTONS["btn-chart"]), createButton(BUTTONS["btn-usage"]), + createButton(BUTTONS["btn-environmental"]), createButton(BUTTONS["btn-diagnostics"]), createButton(BUTTONS["btn-maturity"]), ); @@ -569,6 +570,9 @@ function wireButtons(): void { document.getElementById("btn-maturity")?.addEventListener("click", () => { vscode.postMessage({ command: "showMaturity" }); }); + document.getElementById("btn-environmental")?.addEventListener("click", () => { + vscode.postMessage({ command: "showEnvironmental" }); + }); // Note: No dashboard button handler - users are already on the dashboard } diff --git a/src/webview/details/main.ts b/src/webview/details/main.ts index 60373eb8..73b321fe 100644 --- a/src/webview/details/main.ts +++ b/src/webview/details/main.ts @@ -134,6 +134,7 @@ function renderShell( createButton(BUTTONS['btn-refresh']), createButton(BUTTONS['btn-chart']), createButton(BUTTONS['btn-usage']), + createButton(BUTTONS['btn-environmental']), createButton(BUTTONS['btn-diagnostics']), createButton(BUTTONS['btn-maturity']) ); @@ -158,8 +159,6 @@ function renderShell( sections.append(modelSection); } - sections.append(buildEstimatesSection()); - container.append(header, sections, footer); root.append(themeStyle, style, container); } @@ -189,7 +188,7 @@ function buildMetricsSection( { icon: '๐Ÿ“Š', text: 'Metric' }, { icon: '๐Ÿ“…', text: 'Today' }, { icon: '๐Ÿ“ˆ', text: 'Last 30 Days' }, - { icon: '๐Ÿ“†', text: 'Last Month' }, + { icon: '๐Ÿ“†', text: 'Previous Month' }, { icon: '๐ŸŒ', text: 'Projected Year' } ]; headers.forEach((h, idx) => { @@ -213,10 +212,7 @@ function buildMetricsSection( { label: 'Estimated cost', icon: '๐Ÿช™', color: '#ffd166', today: formatCost(stats.today.estimatedCost), last30Days: formatCost(stats.last30Days.estimatedCost), lastMonth: formatCost(stats.lastMonth.estimatedCost), projected: formatCost(projections.projectedCost) }, { label: 'Sessions', icon: '๐Ÿ“…', color: '#66aaff', today: formatNumber(stats.today.sessions), last30Days: formatNumber(stats.last30Days.sessions), lastMonth: formatNumber(stats.lastMonth.sessions), projected: formatNumber(projections.projectedSessions) }, { label: 'Average interactions/session', icon: '๐Ÿ’ฌ', color: '#8ce0ff', today: formatNumber(stats.today.avgInteractionsPerSession), last30Days: formatNumber(stats.last30Days.avgInteractionsPerSession), lastMonth: formatNumber(stats.lastMonth.avgInteractionsPerSession), projected: 'โ€”' }, - { label: 'Average tokens/session', icon: '๐Ÿ”ข', color: '#7ce38b', today: formatNumber(stats.today.avgTokensPerSession), last30Days: formatNumber(stats.last30Days.avgTokensPerSession), lastMonth: formatNumber(stats.lastMonth.avgTokensPerSession), projected: 'โ€”' }, - { label: 'Estimated COโ‚‚ (g)', icon: '๐ŸŒฑ', color: '#7fe36f', today: `${formatFixed(stats.today.co2, 2)} g`, last30Days: `${formatFixed(stats.last30Days.co2, 2)} g`, lastMonth: `${formatFixed(stats.lastMonth.co2, 2)} g`, projected: `${formatFixed(projections.projectedCo2, 2)} g` }, - { label: 'Estimated water (L)', icon: '๐Ÿ’ง', color: '#6fc3ff', today: `${formatFixed(stats.today.waterUsage, 3)} L`, last30Days: `${formatFixed(stats.last30Days.waterUsage, 3)} L`, lastMonth: `${formatFixed(stats.lastMonth.waterUsage, 3)} L`, projected: `${formatFixed(projections.projectedWater, 3)} L` }, - { label: 'Tree equivalent (yr)', icon: '๐ŸŒณ', color: '#9de67f', today: stats.today.treesEquivalent.toFixed(6), last30Days: stats.last30Days.treesEquivalent.toFixed(6), lastMonth: stats.lastMonth.treesEquivalent.toFixed(6), projected: projections.projectedTrees.toFixed(4) } + { label: 'Average tokens/session', icon: '๐Ÿ”ข', color: '#7ce38b', today: formatNumber(stats.today.avgTokensPerSession), last30Days: formatNumber(stats.last30Days.avgTokensPerSession), lastMonth: formatNumber(stats.lastMonth.avgTokensPerSession), projected: 'โ€”' } ]; rows.forEach(row => { @@ -383,7 +379,7 @@ function buildEditorUsageSection(stats: DetailedStats): HTMLElement | null { { icon: '๐Ÿ“', text: 'Editor', key: 'name' }, { icon: '๐Ÿ“…', text: 'Today', key: 'today' }, { icon: '๐Ÿ“ˆ', text: 'Last 30 Days', key: 'last30Days' }, - { icon: '๐Ÿ“†', text: 'Last Month', key: 'lastMonth' }, + { icon: '๐Ÿ“†', text: 'Previous Month', key: 'lastMonth' }, { icon: '๐ŸŒ', text: 'Projected Year', key: 'projected' } ]; const editorHeaderWraps: HTMLElement[] = []; @@ -537,7 +533,7 @@ function buildModelUsageSection(stats: DetailedStats): HTMLElement | null { { icon: '๐Ÿง ', text: 'Model', key: 'name' }, { icon: '๐Ÿ“…', text: 'Today', key: 'today' }, { icon: '๐Ÿ“ˆ', text: 'Last 30 Days', key: 'last30Days' }, - { icon: '๐Ÿ“†', text: 'Last Month', key: 'lastMonth' }, + { icon: '๐Ÿ“†', text: 'Previous Month', key: 'lastMonth' }, { icon: '๐ŸŒ', text: 'Projected Year', key: 'projected' } ]; const modelHeaderWraps: HTMLElement[] = []; @@ -617,6 +613,9 @@ function wireButtons(): void { const dashboard = document.getElementById('btn-dashboard'); dashboard?.addEventListener('click', () => vscode.postMessage({ command: 'showDashboard' })); + + const environmental = document.getElementById('btn-environmental'); + environmental?.addEventListener('click', () => vscode.postMessage({ command: 'showEnvironmental' })); } async function bootstrap(): Promise { diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index 7594ce27..0f54790a 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -893,6 +893,7 @@ function renderLayout(data: DiagnosticsData): void { ${buttonHtml("btn-details")} ${buttonHtml("btn-chart")} ${buttonHtml("btn-usage")} + ${buttonHtml("btn-environmental")} ${buttonHtml("btn-maturity")} ${data?.backendConfigured ? buttonHtml("btn-dashboard") : ""} @@ -1611,6 +1612,11 @@ function renderLayout(data: DiagnosticsData): void { ?.addEventListener("click", () => vscode.postMessage({ command: "showDashboard" }), ); + document + .getElementById("btn-environmental") + ?.addEventListener("click", () => + vscode.postMessage({ command: "showEnvironmental" }), + ); setupSortHandlers(); setupEditorFilterHandlers(); diff --git a/src/webview/environmental/main.ts b/src/webview/environmental/main.ts new file mode 100644 index 00000000..e671abb6 --- /dev/null +++ b/src/webview/environmental/main.ts @@ -0,0 +1,319 @@ +// Environmental Impact webview +import { el, createButton } from '../shared/domUtils'; +import { BUTTONS } from '../shared/buttonConfig'; +import { formatFixed, formatNumber } from '../shared/formatUtils'; +// CSS imported as text via esbuild +import themeStyles from '../shared/theme.css'; +import styles from './styles.css'; + +// --- Analogy constants --- +/** Average EU petrol car COโ‚‚ emissions per km (grams) */ +const CO2_GRAMS_PER_CAR_KM = 120; +/** COโ‚‚ emitted for one full kettle boil (2 L, EU average grid) (grams) */ +const CO2_GRAMS_PER_KETTLE_BOIL = 20; +/** COโ‚‚ emitted per km on EU intercity rail (grams) */ +const CO2_GRAMS_PER_TRAIN_KM = 41; +/** COโ‚‚ emitted per km flying economy short-haul (grams, ICAO average per passenger) */ +const CO2_GRAMS_PER_FLIGHT_KM = 180; +/** Approximate COโ‚‚ to charge a smartphone once on EU average grid (grams) */ +const CO2_GRAMS_PER_PHONE_CHARGE = 8; +/** COโ‚‚ to run a 10 W LED bulb for one hour on EU average grid (grams) */ +const CO2_GRAMS_PER_LED_HOUR = 3; +/** Water used per minute in a typical shower (liters) */ +const WATER_LITERS_PER_SHOWER_MINUTE = 8; +/** Water per modern washing machine load (liters) */ +const WATER_LITERS_PER_WASHER_LOAD = 50; +/** Water per mug of tea or coffee (liters) */ +const WATER_LITERS_PER_MUG = 0.25; +/** Water in a standard bathtub fill (liters) */ +const WATER_LITERS_PER_BATHTUB = 150; +/** Water per modern dishwasher cycle (liters) */ +const WATER_LITERS_PER_DISHWASHER = 12; +/** Daily drinking water per person (liters) */ +const WATER_LITERS_DAILY_DRINKING = 2; + +type PeriodStats = { + tokens: number; + co2: number; + treesEquivalent: number; + waterUsage: number; +}; + +type EnvironmentalStats = { + today: PeriodStats; + month: PeriodStats; + lastMonth: PeriodStats; + last30Days: PeriodStats; + lastUpdated: string | Date; + backendConfigured?: boolean; +}; + +declare function acquireVsCodeApi(): { + postMessage: (message: any) => void; + setState: (newState: TState) => void; + getState: () => TState | undefined; +}; + +type VSCodeApi = ReturnType; + +declare global { + interface Window { + __INITIAL_ENVIRONMENTAL__?: EnvironmentalStats; + } +} + +const vscode: VSCodeApi = acquireVsCodeApi(); +const initialData = window.__INITIAL_ENVIRONMENTAL__; + +function calculateProjection(last30DaysValue: number): number { + return (last30DaysValue / 30) * 365.25; +} + +/** Adaptive precision: <0.001 โ†’ 6dp, <1 โ†’ 4dp, โ‰ค100 โ†’ 2dp, โ‰ค1000 โ†’ 1dp, >1000 โ†’ 0dp */ +function smartFixed(value: number): string { + if (value < 0.001) { return formatFixed(value, 6); } + if (value < 1) { return formatFixed(value, 4); } + if (value <= 100) { return formatFixed(value, 2); } + if (value <= 1000) { return formatFixed(value, 1); } + return formatFixed(Math.round(value), 0); +} + +type AnalogyItem = { icon: string; text: string }; + +const co2AnalogyItems = (grams: number): AnalogyItem[] => [ + { icon: '๐Ÿš—', text: `${smartFixed(grams / CO2_GRAMS_PER_CAR_KM)} km driving (EU petrol car)` }, + { icon: '๐Ÿš‚', text: `${smartFixed(grams / CO2_GRAMS_PER_TRAIN_KM)} km by train (EU intercity)` }, + { icon: 'โœˆ๏ธ', text: `${smartFixed(grams / CO2_GRAMS_PER_FLIGHT_KM)} km flying (economy, short-haul)` }, + { icon: '๐Ÿซ–', text: `${smartFixed(grams / CO2_GRAMS_PER_KETTLE_BOIL)} kettle boils` }, + { icon: '๐Ÿ“ฑ', text: `${smartFixed(grams / CO2_GRAMS_PER_PHONE_CHARGE)} smartphone charges` }, + { icon: '๐Ÿ’ก', text: `${smartFixed(grams / CO2_GRAMS_PER_LED_HOUR)} hours of LED lighting (10 W)` }, +]; + +const waterAnalogyItems = (liters: number): AnalogyItem[] => [ + { icon: 'โ˜•', text: `${smartFixed(liters / WATER_LITERS_PER_MUG)} mugs of tea/coffee` }, + { icon: '๐Ÿšฟ', text: `${smartFixed(liters / WATER_LITERS_PER_SHOWER_MINUTE)} shower minutes` }, + { icon: '๐Ÿ‘•', text: `${smartFixed(liters / WATER_LITERS_PER_WASHER_LOAD)} washing machine loads` }, + { icon: '๐Ÿ›', text: `${smartFixed(liters / WATER_LITERS_PER_BATHTUB)} standard bathtubs` }, + { icon: '๐Ÿฝ๏ธ', text: `${smartFixed(liters / WATER_LITERS_PER_DISHWASHER)} dishwasher cycles` }, + { icon: '๐Ÿ’ง', text: `${smartFixed(liters / WATER_LITERS_DAILY_DRINKING)} days of drinking water` }, +]; + +const treeAnalogyItems = (fraction: number): AnalogyItem[] => { + const daysAbsorbed = fraction * 365.25; + if (fraction >= 1) { + return [ + { icon: '๐ŸŒณ', text: `${smartFixed(fraction)} ร— a tree's full annual COโ‚‚ absorption` }, + { icon: '๐ŸŒฒ', text: `Plant ${Math.ceil(fraction)} trees to fully offset this per year` }, + ]; + } + return [ + { icon: '๐ŸŒณ', text: `${smartFixed(fraction * 100)} % of one tree's annual absorption` }, + { icon: '๐Ÿ“…', text: `1 tree absorbs this COโ‚‚ in about ${smartFixed(daysAbsorbed)} days` }, + ]; +}; + +function render(stats: EnvironmentalStats): void { + const root = document.getElementById('root'); + if (!root) { return; } + + const projectedCo2 = calculateProjection(stats.last30Days.co2); + const projectedWater = calculateProjection(stats.last30Days.waterUsage); + const projectedTrees = calculateProjection(stats.last30Days.treesEquivalent); + const projectedTokens = Math.round(calculateProjection(stats.last30Days.tokens)); + + const lastUpdated = new Date(stats.lastUpdated); + + root.replaceChildren(); + + const themeStyle = document.createElement('style'); + themeStyle.textContent = themeStyles; + const style = document.createElement('style'); + style.textContent = styles; + + const container = el('div', 'container'); + const header = el('div', 'header'); + const title = el('div', 'title', '๐ŸŒฟ Environmental Impact'); + + const buttonRow = el('div', 'button-row'); + buttonRow.append( + createButton(BUTTONS['btn-refresh']), + createButton(BUTTONS['btn-details']), + createButton(BUTTONS['btn-chart']), + createButton(BUTTONS['btn-usage']), + createButton(BUTTONS['btn-diagnostics']), + createButton(BUTTONS['btn-maturity']) + ); + if (stats.backendConfigured) { + buttonRow.append(createButton(BUTTONS['btn-dashboard'])); + } + header.append(title, buttonRow); + + const footer = el('div', 'footer', `Last updated: ${lastUpdated.toLocaleString()} ยท Updates every 5 minutes`); + const sections = el('div', 'sections'); + + sections.append(buildImpactCards(stats, projectedTokens, projectedCo2, projectedWater, projectedTrees)); + sections.append(buildEstimatesSection()); + + container.append(header, sections, footer); + root.append(themeStyle, style, container); + + wireButtons(); +} + +function buildImpactCards( + stats: EnvironmentalStats, + projectedTokens: number, + projectedCo2: number, + projectedWater: number, + projectedTrees: number +): HTMLElement { + const section = el('div', 'section'); + const heading = el('h3'); + heading.textContent = '๐ŸŒ Impact at a Glance'; + section.append(heading); + + const intro = el('p', 'section-intro'); + intro.textContent = 'All figures are estimates based on average data center energy and water consumption figures. Analogies use European averages. Treat these as order-of-magnitude indicators, not precise measurements.'; + section.append(intro); + + const periods: Array<[string, string, AnalogyItem[] | null]>[] = [ + // Tokens card: 4 periods, no analogies + [ + ['๐Ÿ“… Today', formatNumber(stats.today.tokens), null], + ['๐Ÿ“ˆ Last 30 Days', formatNumber(stats.last30Days.tokens), null], + ['๐Ÿ“† Previous Month', formatNumber(stats.lastMonth.tokens), null], + ['๐ŸŒ Projected Year', formatNumber(projectedTokens), null], + ], + // COโ‚‚ card + [ + ['๐Ÿ“… Today', `${smartFixed(stats.today.co2)} g`, co2AnalogyItems(stats.today.co2)], + ['๐Ÿ“ˆ Last 30 Days', `${smartFixed(stats.last30Days.co2)} g`, co2AnalogyItems(stats.last30Days.co2)], + ['๐Ÿ“† Previous Month', `${smartFixed(stats.lastMonth.co2)} g`, co2AnalogyItems(stats.lastMonth.co2)], + ['๐ŸŒ Projected Year', `${smartFixed(projectedCo2)} g`, co2AnalogyItems(projectedCo2)], + ], + // Water card + [ + ['๐Ÿ“… Today', `${smartFixed(stats.today.waterUsage)} L`, waterAnalogyItems(stats.today.waterUsage)], + ['๐Ÿ“ˆ Last 30 Days', `${smartFixed(stats.last30Days.waterUsage)} L`, waterAnalogyItems(stats.last30Days.waterUsage)], + ['๐Ÿ“† Previous Month', `${smartFixed(stats.lastMonth.waterUsage)} L`, waterAnalogyItems(stats.lastMonth.waterUsage)], + ['๐ŸŒ Projected Year', `${smartFixed(projectedWater)} L`, waterAnalogyItems(projectedWater)], + ], + // Trees card + [ + ['๐Ÿ“… Today', `${smartFixed(stats.today.treesEquivalent)} ๐ŸŒณ`, treeAnalogyItems(stats.today.treesEquivalent)], + ['๐Ÿ“ˆ Last 30 Days', `${smartFixed(stats.last30Days.treesEquivalent)} ๐ŸŒณ`, treeAnalogyItems(stats.last30Days.treesEquivalent)], + ['๐Ÿ“† Previous Month', `${smartFixed(stats.lastMonth.treesEquivalent)} ๐ŸŒณ`, treeAnalogyItems(stats.lastMonth.treesEquivalent)], + ['๐ŸŒ Projected Year', `${smartFixed(projectedTrees)} ๐ŸŒณ`, treeAnalogyItems(projectedTrees)], + ], + ]; + + const metricHeaders: Array<{ icon: string; label: string; color: string }> = [ + { icon: '๐ŸŸฃ', label: 'Tokens (total)', color: '#c37bff' }, + { icon: '๐ŸŒฑ', label: 'Estimated COโ‚‚', color: '#7fe36f' }, + { icon: '๐Ÿ’ง', label: 'Estimated Water', color: '#6fc3ff' }, + { icon: '๐ŸŒณ', label: 'Tree equivalent', color: '#9de67f' }, + ]; + + const cards = el('div', 'metric-cards'); + + periods.forEach((periodCols, i) => { + const card = el('div', 'metric-card'); + + const cardHeader = el('div', 'metric-card-header'); + const iconEl = el('span', 'metric-card-icon', metricHeaders[i].icon); + iconEl.style.color = metricHeaders[i].color; + const labelEl = el('span', 'metric-card-label', metricHeaders[i].label); + cardHeader.append(iconEl, labelEl); + card.append(cardHeader); + + const grid = el('div', 'analogy-grid'); + periodCols.forEach(([periodLabel, primaryValue, analogies]) => { + const col = el('div', 'analogy-col'); + col.append(el('div', 'analogy-col-header', periodLabel)); + col.append(el('div', 'metric-primary-value', primaryValue)); + if (analogies) { + analogies.forEach(item => { + const itemEl = el('div', 'analogy-item'); + const itemIcon = el('span', 'analogy-icon', item.icon); + const itemText = document.createElement('span'); + itemText.textContent = item.text; + itemEl.append(itemIcon, itemText); + col.append(itemEl); + }); + } + grid.append(col); + }); + card.append(grid); + cards.append(card); + }); + + section.append(cards); + return section; +} + +function buildEstimatesSection(): HTMLElement { + const section = el('div', 'section'); + const heading = el('h3'); + heading.textContent = '๐Ÿ’ก Calculation & Estimates'; + section.append(heading); + + const notes = document.createElement('ul'); + notes.className = 'notes'; + + const items = [ + 'Cost estimate uses public API pricing with input/output token counts; GitHub Copilot billing may differ from direct API usage.', + 'Estimated COโ‚‚ is based on ~0.2 g COโ‚‚e per 1,000 tokens (average data center energy mix and PUE).', + 'Estimated water usage is based on ~0.3 L per 1,000 tokens (data center cooling estimates).', + 'Tree equivalent represents the fraction of a single mature tree\'s annual COโ‚‚ absorption (~21 kg/year).', + 'COโ‚‚ analogies: petrol car โ‰ˆ 120 g/km ยท intercity train โ‰ˆ 41 g/km ยท economy flight โ‰ˆ 180 g/km (ICAO avg.) ยท smartphone charge โ‰ˆ 8 g ยท LED bulb โ‰ˆ 3 g/hr (10 W, EU grid) ยท kettle boil โ‰ˆ 20 g.', + 'Water analogies: shower โ‰ˆ 8 L/min ยท washing machine โ‰ˆ 50 L ยท standard bathtub โ‰ˆ 150 L ยท dishwasher โ‰ˆ 12 L ยท mug of tea โ‰ˆ 250 mL ยท daily drinking water โ‰ˆ 2 L/person.', + 'All analogies are order-of-magnitude estimates. Actual values depend on your region\'s energy mix and device efficiency.' + ]; + + items.forEach(text => { + const li = document.createElement('li'); + li.textContent = text; + notes.append(li); + }); + + section.append(notes); + return section; +} + +function wireButtons(): void { + document.getElementById('btn-refresh')?.addEventListener('click', () => vscode.postMessage({ command: 'refresh' })); + document.getElementById('btn-details')?.addEventListener('click', () => vscode.postMessage({ command: 'showDetails' })); + document.getElementById('btn-chart')?.addEventListener('click', () => vscode.postMessage({ command: 'showChart' })); + document.getElementById('btn-usage')?.addEventListener('click', () => vscode.postMessage({ command: 'showUsageAnalysis' })); + document.getElementById('btn-diagnostics')?.addEventListener('click', () => vscode.postMessage({ command: 'showDiagnostics' })); + document.getElementById('btn-maturity')?.addEventListener('click', () => vscode.postMessage({ command: 'showMaturity' })); + document.getElementById('btn-dashboard')?.addEventListener('click', () => vscode.postMessage({ command: 'showDashboard' })); +} + +window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (message.command === 'updateStats') { + render(message.data as EnvironmentalStats); + } +}); + +async function bootstrap(): Promise { + const { provideVSCodeDesignSystem, vsCodeButton } = await import('@vscode/webview-ui-toolkit'); + provideVSCodeDesignSystem().register(vsCodeButton()); + + if (initialData) { + render(initialData); + } else { + const root = document.getElementById('root'); + if (root) { + root.textContent = ''; + const fallback = document.createElement('div'); + fallback.style.padding = '16px'; + fallback.style.color = '#e7e7e7'; + fallback.textContent = 'No data available.'; + root.append(fallback); + } + } +} + +bootstrap(); diff --git a/src/webview/environmental/styles.css b/src/webview/environmental/styles.css new file mode 100644 index 00000000..5f22d46a --- /dev/null +++ b/src/webview/environmental/styles.css @@ -0,0 +1,168 @@ +body { + margin: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.container { + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; + max-width: 1200px; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-bottom: 4px; +} + +.title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sections { + display: flex; + flex-direction: column; + gap: 16px; +} + +.section { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + box-shadow: 0 4px 10px var(--shadow-color); +} + +.section h3 { + margin: 0 0 10px; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-primary); + letter-spacing: 0.2px; +} + +/* --- Metric cards --- */ +.metric-cards { + display: flex; + flex-direction: column; + gap: 16px; +} + +.metric-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 14px 16px; +} + +.metric-card-header { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 12px; +} + +.metric-card-icon { + font-size: 16px; + line-height: 1; +} + +.metric-card-label { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.metric-primary-value { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + padding: 6px 0 10px; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 8px; +} + +.analogy-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.analogy-col { + display: flex; + flex-direction: column; + gap: 6px; +} + +.analogy-col-header { + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + padding-bottom: 5px; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 2px; +} + +.analogy-item { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; +} + +.analogy-icon { + flex-shrink: 0; + width: 20px; + text-align: center; + font-size: 13px; +} + +.notes { + margin: 4px 0 0; + padding-left: 16px; + color: var(--text-secondary); +} + +.notes li { + margin: 4px 0; + line-height: 1.4; +} + +.footer { + color: var(--text-muted); + font-size: 11px; + margin-top: 6px; +} + +.section-intro { + color: var(--text-secondary); + font-size: 12px; + margin: 0 0 10px; + line-height: 1.5; +} diff --git a/src/webview/maturity/main.ts b/src/webview/maturity/main.ts index 5dde6926..3f7dea7b 100644 --- a/src/webview/maturity/main.ts +++ b/src/webview/maturity/main.ts @@ -396,7 +396,10 @@ function renderLayout(data: MaturityData): void { ${buttonHtml('btn-details')} ${buttonHtml('btn-chart')} ${buttonHtml('btn-usage')} - ${buttonHtml('btn-diagnostics')} ${data.backendConfigured ? buttonHtml('btn-dashboard') : ''} + ${buttonHtml('btn-environmental')} + ${buttonHtml('btn-diagnostics')} + ${data.backendConfigured ? buttonHtml('btn-dashboard') : ''} +
@@ -554,6 +557,9 @@ function renderLayout(data: MaturityData): void { document.getElementById('btn-dashboard')?.addEventListener('click', () => { vscode.postMessage({ command: 'showDashboard' }); }); + document.getElementById('btn-environmental')?.addEventListener('click', () => { + vscode.postMessage({ command: 'showEnvironmental' }); + }); // Wire up share to issue button document.getElementById('btn-share-issue')?.addEventListener('click', () => { diff --git a/src/webview/shared/buttonConfig.ts b/src/webview/shared/buttonConfig.ts index 67484774..2b6caa3b 100644 --- a/src/webview/shared/buttonConfig.ts +++ b/src/webview/shared/buttonConfig.ts @@ -3,7 +3,7 @@ * This ensures consistent button IDs, labels, and icons across all webviews. */ -export type ButtonId = 'btn-refresh' | 'btn-details' | 'btn-chart' | 'btn-usage' | 'btn-diagnostics' | 'btn-maturity' | 'btn-dashboard' | 'btn-level-viewer'; +export type ButtonId = 'btn-refresh' | 'btn-details' | 'btn-chart' | 'btn-usage' | 'btn-diagnostics' | 'btn-maturity' | 'btn-dashboard' | 'btn-level-viewer' | 'btn-environmental'; export interface ButtonConfig { id: ButtonId; @@ -47,6 +47,10 @@ export const BUTTONS: Record = { 'btn-level-viewer': { id: 'btn-level-viewer', label: '๐Ÿ” Level Viewer' + }, + 'btn-environmental': { + id: 'btn-environmental', + label: '๐ŸŒฟ Environmental Impact' } }; diff --git a/src/webview/usage/main.ts b/src/webview/usage/main.ts index 3339329b..295536db 100644 --- a/src/webview/usage/main.ts +++ b/src/webview/usage/main.ts @@ -477,6 +477,7 @@ function renderLayout(stats: UsageAnalysisStats): void { ${buttonHtml('btn-refresh')} ${buttonHtml('btn-details')} ${buttonHtml('btn-chart')} + ${buttonHtml('btn-environmental')} ${buttonHtml('btn-diagnostics')} ${buttonHtml('btn-maturity')} ${stats.backendConfigured ? buttonHtml('btn-dashboard') : ''} @@ -644,7 +645,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
-

๐Ÿ“… Last Month

+

๐Ÿ“… Previous Month

Total Tool Calls: ${formatNumber(stats.month.toolCalls.total)}
${renderToolsTable(stats.month.toolCalls.byTool, 10)} @@ -709,7 +710,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
-

๐Ÿ“… Last Month

+

๐Ÿ“… Previous Month

Total MCP Calls: ${formatNumber(stats.month.mcpTools.total)}
${stats.month.mcpTools.total > 0 ? ` @@ -887,7 +888,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
-

๐Ÿ“… Last Month

+

๐Ÿ“… Previous Month

๐Ÿ“Š Avg Models per Conversation
@@ -964,7 +965,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
๐Ÿ“… Today Sessions
${formatNumber(stats.today.sessions)}
๐Ÿ“† Last 30 Days Sessions
${formatNumber(stats.last30Days.sessions)}
-
๐Ÿ“… Last Month Sessions
${formatNumber(stats.month.sessions)}
+
๐Ÿ“… Previous Month Sessions
${formatNumber(stats.month.sessions)}
@@ -995,6 +996,9 @@ function renderLayout(stats: UsageAnalysisStats): void { document.getElementById('btn-dashboard')?.addEventListener('click', () => { vscode.postMessage({ command: 'showDashboard' }); }); + document.getElementById('btn-environmental')?.addEventListener('click', () => { + vscode.postMessage({ command: 'showEnvironmental' }); + }); // Repository analysis buttons document.getElementById('btn-analyse-repo')?.addEventListener('click', () => { diff --git a/test/fixtures/sample-session-data/chatSessions/session-05-twenty-seven-days-ago.json b/test/fixtures/sample-session-data/chatSessions/session-05-twenty-seven-days-ago.json index cb18a7c0..3dd13b36 100644 --- a/test/fixtures/sample-session-data/chatSessions/session-05-twenty-seven-days-ago.json +++ b/test/fixtures/sample-session-data/chatSessions/session-05-twenty-seven-days-ago.json @@ -41,7 +41,7 @@ { "kind": "markdownContent", "content": { - "value": "## Details Panel vs. Usage Analysis Dashboard\n\n### Details Panel\n\nThe **Details Panel** (`Show Token Usage Details`) focuses on **token counts and cost**:\n\n| What it shows | Example |\n|---|---|\n| Tokens today / last 30 days / last month | 15,840 / 1.2M / 130K |\n| Thinking tokens breakdown | 2,400 thinking / 13,440 regular |\n| Estimated cost by period | $0.28 / $62.50 / $7.80 |\n| Per-model breakdown | claude-sonnet: 800K, gpt-4o: 400K |\n| Cloud backend totals (if configured) | 1.3M total from all devices |\n\n**Use it when**: You want to know how much you've spent, which models you use most, and whether token usage is trending up.\n\n### Usage Analysis Dashboard\n\nThe **Usage Analysis Dashboard** (`Show Usage Analysis Dashboard`) focuses on **behavior patterns**:\n\n| What it shows | Example |\n|---|---|\n| Mode breakdown (chat vs agent vs edit) | 45% agent, 40% chat, 15% edit |\n| Tool call patterns | 340 tool calls, top: read_file (120) |\n| Context references | 280 file references, 45 workspace |\n| MCP tool usage | 12 MCP calls: github, playwright |\n| Agent iteration depth | avg 4.2 tool calls per agent turn |\n\n**Use it when**: You want to understand *how* you're using Copilot โ€” are you leveraging agent mode, using context effectively, which tools you rely on?\n\n### When to Use Each\n\n- **Budget tracking**: Details Panel\n- **Productivity insights**: Usage Analysis Dashboard\n- **Model comparison**: Details Panel (per-model section)\n- **Workflow optimization**: Usage Analysis Dashboard\n- **Before/after comparison** (e.g., starting to use MCP tools): Both panels" + "value": "## Details Panel vs. Usage Analysis Dashboard\n\n### Details Panel\n\nThe **Details Panel** (`Show Token Usage Details`) focuses on **token counts and cost**:\n\n| What it shows | Example |\n|---|---|\n| Tokens today / last 30 days / Previous Month | 15,840 / 1.2M / 130K |\n| Thinking tokens breakdown | 2,400 thinking / 13,440 regular |\n| Estimated cost by period | $0.28 / $62.50 / $7.80 |\n| Per-model breakdown | claude-sonnet: 800K, gpt-4o: 400K |\n| Cloud backend totals (if configured) | 1.3M total from all devices |\n\n**Use it when**: You want to know how much you've spent, which models you use most, and whether token usage is trending up.\n\n### Usage Analysis Dashboard\n\nThe **Usage Analysis Dashboard** (`Show Usage Analysis Dashboard`) focuses on **behavior patterns**:\n\n| What it shows | Example |\n|---|---|\n| Mode breakdown (chat vs agent vs edit) | 45% agent, 40% chat, 15% edit |\n| Tool call patterns | 340 tool calls, top: read_file (120) |\n| Context references | 280 file references, 45 workspace |\n| MCP tool usage | 12 MCP calls: github, playwright |\n| Agent iteration depth | avg 4.2 tool calls per agent turn |\n\n**Use it when**: You want to understand *how* you're using Copilot โ€” are you leveraging agent mode, using context effectively, which tools you rely on?\n\n### When to Use Each\n\n- **Budget tracking**: Details Panel\n- **Productivity insights**: Usage Analysis Dashboard\n- **Model comparison**: Details Panel (per-model section)\n- **Workflow optimization**: Usage Analysis Dashboard\n- **Before/after comparison** (e.g., starting to use MCP tools): Both panels" } } ],